feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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 { ModelOption, ModelOptionProps } from './model-option';
|
||||
export { ModelOptionGroup, ModelOptionGroupProps } from './model-option-group';
|
||||
export { ModelOptionThumb } from './model-option-thumb';
|
||||
export { ModelSelectUI, ModelSelectUIProps } from './model-select-ui';
|
||||
export { ModelSelect, ModelSelectProps } from './model-select';
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* 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 cls from 'classnames';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import {
|
||||
IconCozRoleFill,
|
||||
IconCozLightbulbFill,
|
||||
IconCozChatFill,
|
||||
IconCozCodeFill,
|
||||
IconCozDocumentFill,
|
||||
IconCozImageFill,
|
||||
IconCozLightningFill,
|
||||
IconCozMusic,
|
||||
IconCozStarFill,
|
||||
IconCozVideoFill,
|
||||
IconCozWrenchFill,
|
||||
} from '@coze-arch/coze-design/icons';
|
||||
import { Avatar, Tooltip } from '@coze-arch/coze-design';
|
||||
import { type Model, ModelTagValue } from '@coze-arch/bot-api/developer_api';
|
||||
|
||||
export function ModelOptionAvatar({ model }: { model: Model }) {
|
||||
return (
|
||||
<Tooltip
|
||||
trigger={
|
||||
model.model_status_details?.is_upcoming_deprecated ? 'hover' : 'custom'
|
||||
}
|
||||
content={I18n.t('model_list_model_deprecation_date', {
|
||||
date: model.model_status_details?.deprecated_date,
|
||||
})}
|
||||
>
|
||||
<span>
|
||||
<InnerImg
|
||||
model={model}
|
||||
bottomBanner={
|
||||
model.model_status_details?.is_upcoming_deprecated ? (
|
||||
<div
|
||||
className={cls(
|
||||
'absolute bottom-0 left-0',
|
||||
'w-full py-[1px] px-[3px] rounded-b-[6px]',
|
||||
'flex items-center justify-center text-center',
|
||||
'text-[10px] font-medium leading-[14px] break-all',
|
||||
'coz-mg-mask coz-fg-hglt-plus',
|
||||
)}
|
||||
>
|
||||
{I18n.t('model_list_willDeprecated')}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerImg({
|
||||
model: { model_status_details, model_icon },
|
||||
bottomBanner,
|
||||
}: {
|
||||
model: Model;
|
||||
bottomBanner?: ReactNode;
|
||||
}) {
|
||||
if (
|
||||
model_status_details?.is_new_model ||
|
||||
!model_status_details?.model_feature
|
||||
) {
|
||||
return (
|
||||
<Avatar
|
||||
className="shrink-0 rounded-[6px] border border-solid coz-stroke-primary"
|
||||
shape="square"
|
||||
// @ts-expect-error -- semi 类型定义有问题
|
||||
bottomSlot={
|
||||
bottomBanner
|
||||
? {
|
||||
render: () => bottomBanner,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
src={model_icon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const featureIcon = FEATURE_ICON_MAP[model_status_details.model_feature];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
'w-[48px] h-[48px] p-[13px] relative',
|
||||
'shrink-0 rounded-[6px] text-[22px]',
|
||||
featureIcon.color,
|
||||
featureIcon.bg,
|
||||
)}
|
||||
>
|
||||
{featureIcon.icon}
|
||||
{bottomBanner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FEATURE_ICON_MAP: Record<
|
||||
ModelTagValue,
|
||||
{ color: string; bg: string; icon: ReactNode }
|
||||
> = {
|
||||
[ModelTagValue.Flagship]: {
|
||||
icon: <IconCozStarFill />, //旗舰
|
||||
color: 'coz-fg-color-brand',
|
||||
bg: 'coz-mg-hglt',
|
||||
},
|
||||
[ModelTagValue.HighSpeed]: {
|
||||
icon: <IconCozLightningFill />, //高速
|
||||
color: 'coz-fg-color-blue',
|
||||
bg: 'coz-mg-color-blue',
|
||||
},
|
||||
[ModelTagValue.CostPerformance]: {
|
||||
icon: <IconCozChatFill />, //性价比
|
||||
color: 'coz-fg-color-blue',
|
||||
bg: 'coz-mg-color-blue',
|
||||
},
|
||||
[ModelTagValue.LongText]: {
|
||||
icon: <IconCozDocumentFill />, //长文本
|
||||
color: 'coz-fg-color-blue',
|
||||
bg: 'coz-mg-color-blue',
|
||||
},
|
||||
[ModelTagValue.RolePlaying]: {
|
||||
icon: <IconCozRoleFill />, //角色扮演
|
||||
color: 'coz-fg-color-blue',
|
||||
bg: 'coz-mg-color-blue',
|
||||
},
|
||||
[ModelTagValue.ImageUnderstanding]: {
|
||||
icon: <IconCozImageFill />, //图像
|
||||
color: 'coz-fg-color-purple',
|
||||
bg: 'coz-mg-color-purple',
|
||||
},
|
||||
[ModelTagValue.VideoUnderstanding]: {
|
||||
icon: <IconCozVideoFill />, //视频
|
||||
color: 'coz-fg-color-purple',
|
||||
bg: 'coz-mg-color-purple',
|
||||
},
|
||||
[ModelTagValue.AudioUnderstanding]: {
|
||||
icon: <IconCozMusic />, //音频
|
||||
color: 'coz-fg-color-purple',
|
||||
bg: 'coz-mg-color-purple',
|
||||
},
|
||||
[ModelTagValue.CodeSpecialization]: {
|
||||
icon: <IconCozCodeFill />, //代码专精
|
||||
color: 'coz-fg-color-cyan',
|
||||
bg: 'coz-mg-color-cyan',
|
||||
},
|
||||
[ModelTagValue.ToolInvocation]: {
|
||||
icon: <IconCozWrenchFill />, //工具调用
|
||||
color: 'coz-fg-color-cyan',
|
||||
bg: 'coz-mg-color-cyan',
|
||||
},
|
||||
[ModelTagValue.Reasoning]: {
|
||||
icon: <IconCozLightbulbFill />, //推理
|
||||
color: 'coz-fg-color-cyan',
|
||||
bg: 'coz-mg-color-cyan',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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 PropsWithChildren } from 'react';
|
||||
|
||||
import cls from 'classnames';
|
||||
import {
|
||||
IconCozStarFill,
|
||||
IconCozQuestionMarkCircle,
|
||||
} from '@coze-arch/coze-design/icons';
|
||||
import { Avatar, Divider, Tooltip } from '@coze-arch/coze-design';
|
||||
|
||||
export type ModelOptionGroupProps =
|
||||
| {
|
||||
/** 新模型专区 */
|
||||
type: 'new';
|
||||
name: string;
|
||||
tips?: string;
|
||||
}
|
||||
| {
|
||||
/** 普通系列模型 */
|
||||
type?: 'normal';
|
||||
icon: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
tips?: string;
|
||||
};
|
||||
|
||||
export function ModelOptionGroup({
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<ModelOptionGroupProps>) {
|
||||
return (
|
||||
<section>
|
||||
<div className="pt-[12px] pl-[16px] pb-[2px]">
|
||||
{props.type === 'new' ? (
|
||||
<div className="flex items-center gap-[4px] coz-fg-hglt">
|
||||
<IconCozStarFill />
|
||||
<span className="text-[12px] leading-[16px]">{props.name}</span>
|
||||
{props.tips ? (
|
||||
<Tooltip content={props.tips}>
|
||||
<IconCozQuestionMarkCircle className="cursor-pointer coz-fg-secondary" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<Avatar
|
||||
shape="square"
|
||||
className="w-[14px] h-[14px] rounded-[3px] !cursor-default border border-solid coz-stroke-primary"
|
||||
src={props.icon}
|
||||
/>
|
||||
<div
|
||||
className={cls(
|
||||
'flex items-center gap-[4px]',
|
||||
'text-[12px] leading-[16px]',
|
||||
)}
|
||||
>
|
||||
<span className="coz-fg-secondary">{props.name}</span>
|
||||
{props.tips ? (
|
||||
<Tooltip content={props.tips}>
|
||||
<IconCozQuestionMarkCircle className="cursor-pointer coz-fg-secondary" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Divider layout="vertical" />
|
||||
<span className="coz-fg-dim">{props.desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 { I18n } from '@coze-arch/i18n';
|
||||
import { Avatar, Tag } from '@coze-arch/coze-design';
|
||||
import { type Model } from '@coze-arch/bot-api/developer_api';
|
||||
|
||||
/** 极简版 ModelOption,用于 Button 展示或 Select 已选栏 */
|
||||
export function ModelOptionThumb({ model }: { model: Model }) {
|
||||
return (
|
||||
<div className="px-[4px] flex items-center gap-[4px]">
|
||||
<Avatar
|
||||
shape="square"
|
||||
size="extra-extra-small"
|
||||
src={model.model_icon}
|
||||
className="rounded-[4px] border border-solid coz-stroke-primary"
|
||||
/>
|
||||
<span className="text-[14px] leading-[20px] coz-fg-primary">
|
||||
{model.name}
|
||||
</span>
|
||||
{model.model_status_details?.is_upcoming_deprecated ? (
|
||||
<Tag size="mini" color="yellow">
|
||||
{I18n.t('model_list_willDeprecated')}
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
.model-option {
|
||||
&:last-of-type,
|
||||
&.model-option_selected,
|
||||
&:hover,
|
||||
&:has(+ &.model-option_selected),
|
||||
&:has(+ &:hover) {
|
||||
.model-info-border {
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// UI 要改 coze design 默认样式,无奈
|
||||
:global(.coz-tag.coz-tag-mini) {
|
||||
padding-right: 3px;
|
||||
padding-left: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/*
|
||||
* 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 complexity -- ignore */
|
||||
import { type PropsWithChildren, useRef } from 'react';
|
||||
|
||||
import cls from 'classnames';
|
||||
import { useHover } from 'ahooks';
|
||||
import {
|
||||
useBenefitAvailable,
|
||||
PremiumPaywallScene,
|
||||
usePremiumPaywallModal,
|
||||
} from '@coze-studio/premium-components-adapter';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { type Model, ModelTagClass } from '@coze-arch/bot-api/developer_api';
|
||||
import {
|
||||
useBotCreatorContext,
|
||||
BotCreatorScene,
|
||||
} from '@coze-agent-ide/bot-creator-context';
|
||||
import {
|
||||
IconCozLongArrowTopRight,
|
||||
IconCozSetting,
|
||||
IconCozLongArrowUpCircle,
|
||||
IconCozDiamondFill,
|
||||
} from '@coze-arch/coze-design/icons';
|
||||
import { IconButton, Tag, Tooltip, Typography } from '@coze-arch/coze-design';
|
||||
import { OverflowList } from '@blueprintjs/core';
|
||||
|
||||
import { ModelOptionAvatar } from '../model-option-avatar';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
export type ModelOptionProps = {
|
||||
model: Model;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
/** 返回是否切换成功 */
|
||||
onClick: () => boolean;
|
||||
} & (
|
||||
| {
|
||||
enableConfig?: false;
|
||||
}
|
||||
| {
|
||||
enableConfig: true;
|
||||
onConfigClick: () => void;
|
||||
}
|
||||
) &
|
||||
(
|
||||
| {
|
||||
enableJumpDetail?: false;
|
||||
}
|
||||
| {
|
||||
enableJumpDetail: true;
|
||||
/**
|
||||
* 点击跳转模型管理页面
|
||||
*
|
||||
* 因为该组件定位是纯 UI 组件,且不同模块 space id 获取的方式不尽相同,因此跳转行为和 url 的拼接就不内置了
|
||||
*/
|
||||
onDetailClick: (modelId: string) => void;
|
||||
}
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @coze-arch/max-line-per-function
|
||||
export function ModelOption({
|
||||
model,
|
||||
selected,
|
||||
disabled,
|
||||
onClick,
|
||||
...props
|
||||
}: ModelOptionProps) {
|
||||
/** 这个 ref 纯粹为了判断是否 hover */
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const isHovering = useHover(ref);
|
||||
const { scene } = useBotCreatorContext();
|
||||
|
||||
const featureTags = model.model_tag_list
|
||||
?.filter(t => t.tag_class === ModelTagClass.ModelFeature && t.tag_name)
|
||||
.map(t => t.tag_name);
|
||||
const functionTags = model.model_tag_list
|
||||
?.filter(t => t.tag_class === ModelTagClass.ModelFunction && t.tag_name)
|
||||
.map(t => t.tag_name);
|
||||
|
||||
// 付费墙,社区版不支持该功能
|
||||
const isProModel =
|
||||
model.model_status_details?.is_new_model ||
|
||||
model.model_status_details?.is_advanced_model;
|
||||
const isNewModelAvailable = useBenefitAvailable({
|
||||
scene: PremiumPaywallScene.NewModel,
|
||||
});
|
||||
const { node: premiumPaywallModal, open: openPremiumPaywallModal } =
|
||||
usePremiumPaywallModal({ scene: PremiumPaywallScene.NewModel });
|
||||
|
||||
return (
|
||||
<>
|
||||
<article
|
||||
ref={ref}
|
||||
className={cls(
|
||||
'pl-[16px] pr-[12px] w-full relative',
|
||||
'flex gap-[16px] items-center rounded-[12px]',
|
||||
selected
|
||||
? 'coz-mg-hglt hover:coz-mg-hglt-hovered'
|
||||
: 'hover:coz-mg-secondary-hovered active:coz-mg-secondary-pressed',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
// 以下 cls 只为实现 hover、active、last 时隐藏上下分割线(注意分割线在 model-info-border,设计师的小心思)
|
||||
styles['model-option'],
|
||||
// @ts-expect-error -- 不知道为什么会报错
|
||||
{ [styles['model-option_selected']]: selected },
|
||||
)}
|
||||
onClick={() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (isProModel && !isNewModelAvailable) {
|
||||
openPremiumPaywallModal();
|
||||
return;
|
||||
}
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<ModelOptionAvatar model={model} />
|
||||
<div
|
||||
className={cls(
|
||||
'h-[80px] py-[12px] w-full',
|
||||
'flex flex-col overflow-hidden',
|
||||
'border-0 border-b border-solid coz-stroke-primary',
|
||||
styles['model-info-border'],
|
||||
)}
|
||||
style={
|
||||
isHovering
|
||||
? {
|
||||
mask: calcMaskStyle([
|
||||
props.enableConfig,
|
||||
props.enableJumpDetail,
|
||||
]),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="w-full flex items-center gap-[6px] overflow-hidden">
|
||||
<Typography.Title fontSize="14px" ellipsis={{ showTooltip: true }}>
|
||||
{model.name}
|
||||
</Typography.Title>
|
||||
<div className="shrink-0 flex gap-[6px]">
|
||||
{/* 抖音分身场景下不展示改 tag,社区版暂不支持该功能 */}
|
||||
{model.model_status_details?.is_free_model &&
|
||||
scene !== BotCreatorScene.DouyinBot ? (
|
||||
<Tag size="mini" color="primary" className="!coz-mg-plus">
|
||||
{I18n.t('model_list_free')}
|
||||
</Tag>
|
||||
) : null}
|
||||
{isProModel && !isNewModelAvailable ? (
|
||||
<IconCozDiamondFill className="coz-fg-hglt" />
|
||||
) : null}
|
||||
{featureTags?.length
|
||||
? featureTags.map(feature => (
|
||||
<Tag
|
||||
key={feature}
|
||||
size="mini"
|
||||
color="primary"
|
||||
className="!bg-transparent !border border-solid coz-stroke-plus"
|
||||
>
|
||||
{feature}
|
||||
</Tag>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-[12px] leading-[16px] coz-fg-dim overflow-hidden">
|
||||
<ModelTag isFirst>
|
||||
{((model.model_quota?.token_limit || 0) / 1024).toFixed(0)}K
|
||||
</ModelTag>
|
||||
<ModelTag
|
||||
isLast={!functionTags?.length}
|
||||
className="flex items-center gap-[4px]"
|
||||
>
|
||||
<span>{model.model_name}</span>
|
||||
{model.model_status_details?.update_info ? (
|
||||
<Tooltip content={model.model_status_details.update_info}>
|
||||
<IconCozLongArrowUpCircle className="ml-[2px] coz-fg-hglt-green" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</ModelTag>
|
||||
{functionTags?.length ? (
|
||||
<Tooltip content={functionTags.join(IS_OVERSEA ? ', ' : '、')}>
|
||||
<span className="overflow-hidden">
|
||||
<OverflowList
|
||||
items={functionTags}
|
||||
visibleItemRenderer={(item, idx) => (
|
||||
<ModelTag
|
||||
key={idx}
|
||||
isLast={idx === functionTags.length - 1}
|
||||
>
|
||||
{item}
|
||||
</ModelTag>
|
||||
)}
|
||||
overflowRenderer={restItems => (
|
||||
<span className="pl-[6px] flex items-center">{`+${restItems.length}`}</span>
|
||||
)}
|
||||
collapseFrom="end"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
<Typography.Text
|
||||
className="mt-[4px] text-[12px] leading-[16px] coz-fg-secondary"
|
||||
ellipsis={{ showTooltip: true }}
|
||||
>
|
||||
{model.model_brief_desc}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{isHovering ? (
|
||||
<div className="absolute right-[12px] h-full flex items-center gap-[3px]">
|
||||
{props.enableConfig ? (
|
||||
<IconButton
|
||||
icon={<IconCozSetting />}
|
||||
color="secondary"
|
||||
size="default"
|
||||
data-testid="model_select_option.config_btn"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 付费墙拦截
|
||||
if (isProModel && !isNewModelAvailable) {
|
||||
openPremiumPaywallModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
props.onConfigClick();
|
||||
return;
|
||||
}
|
||||
const success = onClick();
|
||||
if (success) {
|
||||
setTimeout(() => props.onConfigClick());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{props.enableJumpDetail ? (
|
||||
<IconButton
|
||||
icon={<IconCozLongArrowTopRight />}
|
||||
color="secondary"
|
||||
size="default"
|
||||
data-testid="model_select_option.detail_btn"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
props.onDetailClick(String(model.model_type));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
{premiumPaywallModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelTag({
|
||||
isFirst,
|
||||
isLast,
|
||||
className,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
className?: string;
|
||||
}>) {
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
{ 'pl-[6px]': !isFirst },
|
||||
'shrink-0 flex items-center gap-[6px]',
|
||||
)}
|
||||
>
|
||||
<span className={className}>{children}</span>
|
||||
{isLast ? null : (
|
||||
<span className="h-[9px] border-0 border-r border-solid coz-stroke-primary" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* hover 展示若干图标(比如跳转模型详情页、详细配置)时,要对图标下的内容有个渐变遮罩效果
|
||||
* 该方法用于计算遮罩样式
|
||||
*/
|
||||
function calcMaskStyle(buttonVisible: Array<boolean | undefined>) {
|
||||
const btnNum = buttonVisible.reduce(
|
||||
(prevNum, showBtn) => prevNum + (showBtn ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
if (btnNum === 0) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
const BTN_WIDTH = 32;
|
||||
const BTN_GAP = 3;
|
||||
/** 不随按钮数量变化的遮罩固定宽度 */
|
||||
const PRESET_PADDING = 16;
|
||||
/** 遮罩的渐变宽度 */
|
||||
const MASK_WIDTH = 24;
|
||||
|
||||
const gradientStart =
|
||||
btnNum * BTN_WIDTH + (btnNum - 1) * BTN_GAP + PRESET_PADDING;
|
||||
const gradientEnd = gradientStart + MASK_WIDTH;
|
||||
return `linear-gradient(to left, rgba(0,0,0,0), rgba(0,0,0,0) ${gradientStart}px, #fff ${gradientEnd}px)`;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* 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, useRef, useState } from 'react';
|
||||
|
||||
import { isBoolean } from 'lodash-es';
|
||||
import cls from 'classnames';
|
||||
import { Popover, type PopoverProps } from '@coze-arch/coze-design';
|
||||
import { IconCozArrowDown } from '@coze-arch/bot-icons';
|
||||
import { type Model } from '@coze-arch/bot-api/developer_api';
|
||||
|
||||
import { PopoverModelListView } from '../popover-model-list-view';
|
||||
import {
|
||||
type ModelConfigProps,
|
||||
PopoverModelConfigView,
|
||||
} from '../popover-model-config-view';
|
||||
import { ModelOptionThumb } from '../model-option-thumb';
|
||||
|
||||
export interface ModelSelectUIProps {
|
||||
/**
|
||||
* 是否禁止弹出 popover
|
||||
*
|
||||
* 目前内部实现既支持 disabled 时直接不允许弹出 popover(与历史逻辑一致)
|
||||
* 也支持允许弹出 popover 和查看详细配置但禁止编辑
|
||||
* 需求变更时可灵活修改
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/** 当前选中的模型 */
|
||||
selectedModelId: string;
|
||||
/**
|
||||
* 是否展示跳转到模型详情页(/space/:space_id/model/:model_id)按钮
|
||||
* @default false
|
||||
*/
|
||||
enableJumpDetail?:
|
||||
| {
|
||||
spaceId: string;
|
||||
}
|
||||
| false;
|
||||
/**
|
||||
* 模型选择的变更事件
|
||||
*
|
||||
* 返回值表示是否成功切换,对部分后续事件会有影响,比如自动关闭 popover
|
||||
* 不显式 return 则视为 true
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- 要实现【要么不用 return,要么必须 return boolean】没别的办法了啊
|
||||
onModelChange: (model: Model) => boolean | void;
|
||||
modelList: Model[];
|
||||
/**
|
||||
* 允许业务侧自定义触发器展示,命名对齐 semi select 组件
|
||||
*
|
||||
* @param model - 当 selectedModelId 找不到对应的 model 时,这里则会传入 undefined
|
||||
*/
|
||||
triggerRender?: (model?: Model, popoverVisible?: boolean) => ReactNode;
|
||||
/**
|
||||
* workflow 等不允许详细配置的业务会有 clickToHide 的诉求
|
||||
* @default false
|
||||
*/
|
||||
clickToHide?: boolean;
|
||||
/** @default bottomLeft */
|
||||
popoverPosition?: PopoverProps['position'];
|
||||
/** @default true */
|
||||
popoverAutoAdjustOverflow?: boolean;
|
||||
/** trigger 的 className。若传入 triggerRender 则完全由 triggerRender 接管渲染,该参数不再起作用 */
|
||||
className?: string;
|
||||
popoverClassName?: string;
|
||||
/**
|
||||
* 若业务侧自行在组件外部插入 Modal,则点击 Modal 也会触发 onClickOutSide 导致 popover 关闭
|
||||
* 若不希望 popover 意外关闭,则需要将 Modal 通过 modalSlot 传入
|
||||
*
|
||||
* (甚至不需要设置 getPopupContainer,此时 Modal 的挂载层和 ModelSelect 的 Popover 的挂载层依然不同,但却神秘地不会再触发 onClickOutSide 了,semi 牛逼)
|
||||
*/
|
||||
modalSlot?: ReactNode;
|
||||
|
||||
/** 模型详细配置信息,不传则隐藏详细配置的按钮入口 */
|
||||
modelConfigProps?: ModelConfigProps;
|
||||
|
||||
/** 弹窗有多种渲染场景,提供选项来定制渲染层级已避免覆盖 */
|
||||
zIndex?: number;
|
||||
|
||||
/** 模型列表额外头部插槽 */
|
||||
modelListExtraHeaderSlot?: ReactNode;
|
||||
|
||||
/** 是否默认展开模型列表 */
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export function ModelSelectUI({
|
||||
className,
|
||||
disabled,
|
||||
enableJumpDetail,
|
||||
popoverClassName,
|
||||
selectedModelId,
|
||||
modelList,
|
||||
onModelChange,
|
||||
triggerRender,
|
||||
modelListExtraHeaderSlot,
|
||||
clickToHide = false,
|
||||
popoverPosition = 'bottomLeft',
|
||||
popoverAutoAdjustOverflow = true,
|
||||
modalSlot,
|
||||
modelConfigProps,
|
||||
zIndex = 999,
|
||||
defaultOpen = false,
|
||||
}: ModelSelectUIProps) {
|
||||
/** 为了实现 Popover 跟 Select 宽度一致,通过该 ref 获取 Select 宽度(若传入 triggerRender 则不再需要保持一致) */
|
||||
const selectRef = useRef<HTMLDivElement>(null);
|
||||
const [popoverVisible, setPopoverVisible] = useState(defaultOpen);
|
||||
const [detailConfigVisible, setDetailConfigVisible] = useState(false);
|
||||
|
||||
const selectedModel = modelList.find(
|
||||
({ model_type }) => selectedModelId === String(model_type),
|
||||
);
|
||||
|
||||
// 需要实现 group + custom option 效果,Select 组件兼容性不佳,不得不自行实现 Popover
|
||||
return (
|
||||
<Popover
|
||||
zIndex={zIndex}
|
||||
stopPropagation
|
||||
autoAdjustOverflow={popoverAutoAdjustOverflow}
|
||||
visible={popoverVisible}
|
||||
trigger="click"
|
||||
position={popoverPosition}
|
||||
onClickOutSide={() => {
|
||||
setPopoverVisible(false);
|
||||
setDetailConfigVisible(false);
|
||||
}}
|
||||
className={cls('!p-0')}
|
||||
content={
|
||||
<div
|
||||
className={cls(
|
||||
'w-[480px] max-h-[50vh] !p-0 overflow-hidden',
|
||||
popoverClassName,
|
||||
)}
|
||||
style={
|
||||
selectRef.current
|
||||
? { width: selectRef.current.clientWidth }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{modelConfigProps ? (
|
||||
<PopoverModelConfigView
|
||||
disabled={disabled}
|
||||
visible={detailConfigVisible}
|
||||
selectedModel={selectedModel}
|
||||
onClose={() => setDetailConfigVisible(false)}
|
||||
modelConfigProps={modelConfigProps}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<PopoverModelListView
|
||||
// 用 hidden 而不是直接条件性挂载以便保留 scrollTop,设计师的小心思
|
||||
hidden={detailConfigVisible}
|
||||
disabled={disabled}
|
||||
selectedModelId={selectedModelId}
|
||||
selectedModel={selectedModel}
|
||||
modelList={modelList}
|
||||
extraHeaderSlot={modelListExtraHeaderSlot}
|
||||
onModelClick={(m: Model) => {
|
||||
const res = onModelChange(m);
|
||||
const success = isBoolean(res) ? res : true;
|
||||
if (success && clickToHide) {
|
||||
setPopoverVisible(false);
|
||||
setDetailConfigVisible(false);
|
||||
}
|
||||
return success;
|
||||
}}
|
||||
enableJumpDetail={!!enableJumpDetail}
|
||||
onDetailClick={modelId => {
|
||||
if (!enableJumpDetail) {
|
||||
return;
|
||||
}
|
||||
window.open(
|
||||
`/space/${enableJumpDetail.spaceId}/model/${modelId}`,
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
enableConfig={!!modelConfigProps}
|
||||
onConfigClick={() => {
|
||||
if (!modelConfigProps) {
|
||||
return;
|
||||
}
|
||||
setDetailConfigVisible(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{modalSlot}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{triggerRender ? (
|
||||
<span
|
||||
className={disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setPopoverVisible(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{triggerRender(selectedModel, popoverVisible)}
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
ref={selectRef}
|
||||
className={cls(
|
||||
'w-full p-[4px] flex items-center justify-between rounded-[8px]',
|
||||
'overflow-hidden cursor-pointer border border-solid',
|
||||
'hover:coz-mg-secondary-hovered active:coz-mg-secondary-pressed',
|
||||
popoverVisible ? 'coz-stroke-hglt' : 'coz-stroke-primary',
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setPopoverVisible(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ModelOptionThumb
|
||||
model={selectedModel || { name: selectedModelId }}
|
||||
/>
|
||||
<IconCozArrowDown className="coz-fg-secondary" />
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { isBoolean } from 'lodash-es';
|
||||
import { useMount } from 'ahooks';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Modal } from '@coze-arch/coze-design';
|
||||
import {
|
||||
usePremiumStore,
|
||||
usePremiumType,
|
||||
} from '@coze-studio/premium-store-adapter';
|
||||
import {
|
||||
useBenefitAvailable,
|
||||
usePremiumManageModal,
|
||||
PremiumPaywallScene,
|
||||
} from '@coze-studio/premium-components-adapter';
|
||||
|
||||
import { ModelSelectUI, type ModelSelectUIProps } from '../model-select-ui';
|
||||
|
||||
export interface ModelSelectProps extends ModelSelectUIProps {
|
||||
/**
|
||||
* 是否允许选择高级模型/新模型,否则内置弹窗拦截
|
||||
* 当不传或设置为 auto 时,则由组件内置判断用户是否是付费用户(国内专业版,海外 premium)
|
||||
* @default auto
|
||||
*/
|
||||
canSelectSuperiorModel?: boolean | 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* 该组件相比 ModelSelectUI 单纯多了付费拦截功能
|
||||
*/
|
||||
export function ModelSelect({
|
||||
onModelChange,
|
||||
canSelectSuperiorModel: canSelectSuperiorModelProps,
|
||||
modalSlot,
|
||||
modelListExtraHeaderSlot,
|
||||
...restProps
|
||||
}: ModelSelectProps) {
|
||||
const { fetchPremiumPlan, fetchPremiumPlans } = usePremiumStore(
|
||||
useShallow(s => ({
|
||||
fetchPremiumPlans: s.fetchPremiumPlans,
|
||||
fetchPremiumPlan: s.fetchPremiumPlan,
|
||||
})),
|
||||
);
|
||||
// 国内:是否允许使用新模型/高级模型
|
||||
const isBenefitAvailable = useBenefitAvailable({
|
||||
scene: PremiumPaywallScene.NewModel,
|
||||
});
|
||||
|
||||
/** 海外是否为 premium */
|
||||
const { isFree } = usePremiumType();
|
||||
|
||||
const canSelectSuperiorModel = isBoolean(canSelectSuperiorModelProps)
|
||||
? canSelectSuperiorModelProps
|
||||
: IS_OVERSEA
|
||||
? !isFree
|
||||
: isBenefitAvailable;
|
||||
const [upgradeModalState, setUpgradeModalState] = useState<{
|
||||
type?: 'new' | 'advance';
|
||||
visible: boolean;
|
||||
}>({ visible: false });
|
||||
|
||||
const { node: premiumManageModal, open: openPremiumModal } =
|
||||
usePremiumManageModal();
|
||||
|
||||
useMount(() => {
|
||||
if (IS_OVERSEA) {
|
||||
fetchPremiumPlans().then(fetchPremiumPlan);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<ModelSelectUI
|
||||
modelListExtraHeaderSlot={modelListExtraHeaderSlot}
|
||||
onModelChange={m => {
|
||||
const isFreeModel =
|
||||
!m.model_status_details?.is_new_model &&
|
||||
!m.model_status_details?.is_advanced_model;
|
||||
if (canSelectSuperiorModel || isFreeModel) {
|
||||
return onModelChange(m);
|
||||
}
|
||||
|
||||
setUpgradeModalState({
|
||||
visible: true,
|
||||
type: m.model_status_details?.is_new_model ? 'new' : 'advance',
|
||||
});
|
||||
return false;
|
||||
}}
|
||||
modalSlot={
|
||||
<>
|
||||
<Modal
|
||||
// ModelSelect 用到的 Popover 组件弹层默认 z-index 为 1030
|
||||
zIndex={1031}
|
||||
visible={upgradeModalState.visible}
|
||||
title={
|
||||
upgradeModalState.type === 'new'
|
||||
? I18n.t('model_list_upgrade_to_pro_version')
|
||||
: I18n.t('model_list_upgrade_to_pro_version_advancedModel')
|
||||
}
|
||||
cancelText={I18n.t('Cancel')}
|
||||
okText={I18n.t('model_list_upgrade_button')}
|
||||
onOk={() => {
|
||||
if (IS_CN_REGION) {
|
||||
openPremiumModal();
|
||||
// 这么操作是为了在关闭动画过程中防止 modal 内容跳变
|
||||
setUpgradeModalState(s => ({ ...s, visible: false }));
|
||||
} else {
|
||||
window.open('/premium', '_blank');
|
||||
}
|
||||
setUpgradeModalState(s => ({ ...s, visible: false }));
|
||||
}}
|
||||
onCancel={() =>
|
||||
setUpgradeModalState(s => ({ ...s, visible: false }))
|
||||
}
|
||||
>
|
||||
{upgradeModalState.type === 'new'
|
||||
? I18n.t('model_list_ensure_service_quality')
|
||||
: I18n.t('model_list_upgrade_to_pro_advanced_tips')}
|
||||
</Modal>
|
||||
{modalSlot}
|
||||
{premiumManageModal}
|
||||
</>
|
||||
}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 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, useEffect, useMemo } from 'react';
|
||||
|
||||
import { omit } from 'lodash-es';
|
||||
import cls from 'classnames';
|
||||
import { useCreation } from 'ahooks';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import {
|
||||
IconCozArrowLeft,
|
||||
IconCozWarningCircleFill,
|
||||
} from '@coze-arch/coze-design/icons';
|
||||
import { IconButton, Loading } from '@coze-arch/coze-design';
|
||||
import { CustomError } from '@coze-arch/bot-error';
|
||||
import { type Model, type ModelInfo } from '@coze-arch/bot-api/developer_api';
|
||||
|
||||
import { modelFormComponentMap } from '../../model-form/type';
|
||||
import { primitiveExhaustiveCheck } from '../../../utils/exhaustive-check';
|
||||
import {
|
||||
useHandleModelForm,
|
||||
type UseHandleModelFormProps,
|
||||
} from '../../../hooks/model-form/use-handle-model-form';
|
||||
import { type ModelFormContextProps } from '../../../context/model-form-context/type';
|
||||
import { ModelFormProvider } from '../../../context/model-form-context/context';
|
||||
import {
|
||||
type FormilyCoreType,
|
||||
type FormilyReactType,
|
||||
} from '../../../context/formily-context/type';
|
||||
import { useFormily } from '../../../context/formily-context';
|
||||
|
||||
export interface ModelConfigProps
|
||||
extends Pick<ModelFormContextProps, 'hideDiversityCollapseButton'> {
|
||||
modelStore: UseHandleModelFormProps['modelStore'];
|
||||
/**
|
||||
* 模型配置更新
|
||||
*
|
||||
* 需要注意切换模型时,会先触发 onModelChange,由外部传入更新后的 selectedModelId,此后内部会计算新模型的 config 并触发 onConfigChange
|
||||
*
|
||||
* 理想数据流是切换模型触发 onModelChange 后,外部一并传入新的 selectedModelId 和 currentConfig。或者由 onModelChange 同时抛出新的 modelId 和 config。
|
||||
* 目前这样虽然有点挫,但由于历史设计原因,改造成上述方式成本略高,暂保持现状。
|
||||
*/
|
||||
onConfigChange: (value: ModelInfo) => void;
|
||||
currentConfig: ModelInfo;
|
||||
/** 当前 agent 是 single 还是 mulit */
|
||||
agentType: 'single' | 'multi';
|
||||
/** 明确diff类型, 透传给getSchema。model-diff情况下不展示携带上下文轮数影响 */
|
||||
diffType?: 'prompt-diff' | 'model-diff';
|
||||
}
|
||||
|
||||
interface PopoverModelConfigViewProps {
|
||||
/**
|
||||
* 需要持续保留表单实例,以便复用无比复杂的「切换模型时初始化详细配置」的逻辑
|
||||
*
|
||||
* 理想做法是在外层业务侧 onModelChange 时重置 config 值
|
||||
* 但一是该逻辑过于复杂,难以独立抽出初始化方法;
|
||||
* 二是 useHandleModelForm 使用成本又极高,不适合放到最外层业务侧去调用
|
||||
*/
|
||||
visible: boolean;
|
||||
disabled?: boolean;
|
||||
selectedModel?: Model;
|
||||
onClose: () => void;
|
||||
modelConfigProps: ModelConfigProps;
|
||||
}
|
||||
|
||||
/** Popover 的 模型配置状态,对应列表状态。单纯为了避免组件过大而做的拆分 */
|
||||
export function PopoverModelConfigView({
|
||||
visible,
|
||||
disabled,
|
||||
selectedModel,
|
||||
onClose,
|
||||
modelConfigProps,
|
||||
}: PopoverModelConfigViewProps) {
|
||||
const formilyInitState = useInitFormily();
|
||||
return (
|
||||
<div
|
||||
className={cls('h-full p-[16px] flex flex-col gap-[12px] overflow-auto', {
|
||||
hidden: !visible,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<IconButton
|
||||
icon={<IconCozArrowLeft />}
|
||||
color="secondary"
|
||||
// size="small"
|
||||
onClick={e => {
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<span className="text-[16px] leading-[22px] font-medium coz-fg-plus">
|
||||
{I18n.t('model_list_model_setting', { model: selectedModel?.name })}
|
||||
</span>
|
||||
</div>
|
||||
{formilyInitState.success ? (
|
||||
<ModelForm
|
||||
disabled={disabled}
|
||||
currentModelId={
|
||||
selectedModel?.model_type ? String(selectedModel?.model_type) : ''
|
||||
}
|
||||
modelConfigProps={modelConfigProps}
|
||||
{...formilyInitState.formilyPkg}
|
||||
/>
|
||||
) : (
|
||||
formilyInitState.node
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelFormProps {
|
||||
disabled?: boolean;
|
||||
currentModelId: string;
|
||||
formilyCore: FormilyCoreType;
|
||||
formilyReact: FormilyReactType;
|
||||
modelConfigProps: ModelConfigProps;
|
||||
}
|
||||
function ModelForm({
|
||||
disabled,
|
||||
currentModelId,
|
||||
formilyCore,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- FormProvider 不适合用别的格式
|
||||
formilyReact: { createSchemaField, FormProvider },
|
||||
modelConfigProps: {
|
||||
currentConfig,
|
||||
onConfigChange,
|
||||
modelStore,
|
||||
agentType,
|
||||
hideDiversityCollapseButton,
|
||||
diffType,
|
||||
},
|
||||
}: ModelFormProps) {
|
||||
const { createForm } = formilyCore;
|
||||
const form = useCreation(() => createForm(), [currentModelId]);
|
||||
const SchemaField = useCreation(
|
||||
() => createSchemaField({ components: modelFormComponentMap }),
|
||||
[],
|
||||
);
|
||||
|
||||
const { getSchema, handleFormInit, handleFormUnmount } = useHandleModelForm({
|
||||
currentModelId,
|
||||
editable: !disabled,
|
||||
getModelRecord: () => currentConfig,
|
||||
onValuesChange: ({ values }) => {
|
||||
onConfigChange(values);
|
||||
},
|
||||
modelStore,
|
||||
});
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
getSchema({
|
||||
currentModelId,
|
||||
isSingleAgent: agentType === 'single',
|
||||
diffType,
|
||||
}),
|
||||
[currentModelId, agentType, diffType],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 在 promise executor 中执行回调,其中的错误会异步产生 promise rejection ,而不是导致页面白屏
|
||||
new Promise(() => handleFormInit(form, formilyCore));
|
||||
|
||||
return handleFormUnmount;
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<ModelFormProvider
|
||||
hideDiversityCollapseButton={hideDiversityCollapseButton}
|
||||
>
|
||||
<FormProvider form={form}>
|
||||
<SchemaField schema={schema} />
|
||||
</FormProvider>
|
||||
</ModelFormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function useInitFormily():
|
||||
| {
|
||||
success: true;
|
||||
formilyPkg: {
|
||||
formilyCore: FormilyCoreType;
|
||||
formilyReact: FormilyReactType;
|
||||
};
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
node: ReactNode;
|
||||
} {
|
||||
const { formilyModule, retryImportFormily } = useFormily();
|
||||
|
||||
if (formilyModule.status === 'loading' || formilyModule.status === 'unInit') {
|
||||
return { success: false, node: <Loading loading /> };
|
||||
}
|
||||
|
||||
if (formilyModule.status === 'error') {
|
||||
return {
|
||||
success: false,
|
||||
node: (
|
||||
<div className="h-full flex items-center gap-y-[8px] text-[14px]">
|
||||
<IconCozWarningCircleFill
|
||||
// 该值迁移自 src/components/model-form/index.tsx
|
||||
className="text-[#FF2710]"
|
||||
/>
|
||||
<div className="font-semibold leading-[22px]">
|
||||
<span>{I18n.t('model_form_fail_text')}</span>
|
||||
<span
|
||||
// 该值迁移自 src/components/model-form/index.tsx
|
||||
className="cursor-pointer text-[#4D53E8]"
|
||||
onClick={retryImportFormily}
|
||||
>
|
||||
{I18n.t('model_form_fail_retry')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (formilyModule.status === 'ready') {
|
||||
return {
|
||||
success: true,
|
||||
formilyPkg: omit(formilyModule, ['status']),
|
||||
};
|
||||
}
|
||||
|
||||
primitiveExhaustiveCheck(formilyModule.status);
|
||||
throw new CustomError('normal_error', 'unrecognized formilyModule.status');
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* 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, useMemo } from 'react';
|
||||
|
||||
import { groupBy } from 'lodash-es';
|
||||
import cls from 'classnames';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { type Model } from '@coze-arch/bot-api/developer_api';
|
||||
|
||||
import { ModelOptionGroup } from '../model-option-group';
|
||||
import { ModelOption } from '../model-option';
|
||||
|
||||
/** Popover 的 模型列表状态,对应详细配置状态。单纯为了避免组件过大而做的拆分 */
|
||||
export function PopoverModelListView({
|
||||
hidden,
|
||||
disabled,
|
||||
selectedModelId,
|
||||
selectedModel,
|
||||
modelList,
|
||||
extraHeaderSlot,
|
||||
onModelClick,
|
||||
onDetailClick,
|
||||
onConfigClick,
|
||||
enableConfig,
|
||||
enableJumpDetail,
|
||||
}: {
|
||||
/** 是否将列表设置为 display: none(为了保留 scrollTop 信息) */
|
||||
hidden: boolean;
|
||||
disabled?: boolean;
|
||||
selectedModelId: string;
|
||||
selectedModel: Model | undefined;
|
||||
modelList: Model[];
|
||||
/** 额外头部插槽 */
|
||||
extraHeaderSlot?: ReactNode;
|
||||
/** 返回是否切换成功 */
|
||||
onModelClick: (model: Model) => boolean;
|
||||
onDetailClick: (modelId: string) => void;
|
||||
onConfigClick: (model: Model) => void;
|
||||
enableConfig?: boolean;
|
||||
enableJumpDetail?: boolean;
|
||||
}) {
|
||||
const { modelGroups } = useMemo(() => {
|
||||
/** 开源版本不进行分类 平铺展示 */
|
||||
if (IS_OPEN_SOURCE) {
|
||||
return { modelGroups: [modelList] };
|
||||
}
|
||||
const modelSeriesGroups = groupBy(
|
||||
modelList,
|
||||
model => model.model_series?.series_name,
|
||||
);
|
||||
return {
|
||||
modelGroups: Object.values(modelSeriesGroups).filter(
|
||||
(group): group is Model[] => !!group?.length,
|
||||
),
|
||||
};
|
||||
}, [modelList]);
|
||||
|
||||
const renderModelOption = (model: Model) => (
|
||||
<ModelOption
|
||||
key={model.model_type}
|
||||
model={model}
|
||||
disabled={disabled}
|
||||
selected={String(model.model_type) === selectedModelId}
|
||||
onClick={() => onModelClick(model)}
|
||||
enableJumpDetail={enableJumpDetail}
|
||||
onDetailClick={onDetailClick}
|
||||
enableConfig={
|
||||
enableConfig &&
|
||||
// 在 disabled 状态下,只能查看选中模型的详细配置
|
||||
(!disabled || String(model.model_type) === selectedModelId)
|
||||
}
|
||||
onConfigClick={() => {
|
||||
onConfigClick(model);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
'max-h-[inherit]', // https://stackoverflow.com/questions/14262938/child-with-max-height-100-overflows-parent
|
||||
'p-[8px] flex flex-col gap-[8px] overflow-auto',
|
||||
{
|
||||
hidden,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between pl-4 pr-2 box-content h-[32px] pb-2 pt-1">
|
||||
<div className="text-xxl font-medium coz-fg-plus">
|
||||
{I18n.t('model_selection')}
|
||||
</div>
|
||||
{extraHeaderSlot}
|
||||
</div>
|
||||
{selectedModel?.model_status_details?.is_upcoming_deprecated ? (
|
||||
<section className="p-[12px] pl-[16px] rounded-[8px] coz-mg-hglt-yellow">
|
||||
<div className="text-[14px] leading-[20px] font-medium coz-fg-plus">
|
||||
{I18n.t('model_list_model_deprecation_notice')}
|
||||
</div>
|
||||
<div className="text-[12px] leading-[16px] coz-fg-primary">
|
||||
{I18n.t('model_list_model_switch_announcement', {
|
||||
model_deprecated: selectedModel.name,
|
||||
date: selectedModel.model_status_details.deprecated_date,
|
||||
model_up: selectedModel.model_status_details.replace_model_name,
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{modelGroups.map((group, idx) => {
|
||||
if (IS_OPEN_SOURCE) {
|
||||
return group.map(renderModelOption);
|
||||
}
|
||||
return (
|
||||
<ModelOptionGroup
|
||||
key={group[0]?.model_series?.series_name ?? idx}
|
||||
type={
|
||||
group[0]?.model_status_details?.is_new_model ? 'new' : 'normal'
|
||||
}
|
||||
icon={group[0]?.model_series?.icon_url || ''}
|
||||
name={group[0]?.model_series?.series_name || ''}
|
||||
desc={I18n.t('model_list_model_company', {
|
||||
company: group[0]?.model_series?.model_vendor || '',
|
||||
})}
|
||||
tips={group[0]?.model_series?.model_tips || ''}
|
||||
>
|
||||
{group.map(renderModelOption)}
|
||||
</ModelOptionGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user