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,326 @@
/*
* 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, type FC } from 'react';
import { groupBy } from 'lodash-es';
import { ToolGroupKey, ToolKey } from '@coze-agent-ide/tool-config';
import {
type useRegisteredToolKeyConfigList,
abilityKey2ModelFunctionConfigType,
} from '@coze-agent-ide/tool';
import { I18n } from '@coze-arch/i18n';
import {
type Model,
ModelFuncConfigStatus,
ModelFuncConfigType,
} from '@coze-arch/bot-api/developer_api';
import { useBotSkillStore } from '@coze-studio/bot-detail-store/bot-skill';
import { mergeModelFuncConfigStatus } from '@coze-agent-ide/bot-editor-context-store';
import { IconCozCross } from '@coze-arch/coze-design/icons';
import { Button, Checkbox, Modal, IconButton, Space } from '@coze-arch/coze-design';
type IRegisteredToolKeyConfig = ReturnType<
typeof useRegisteredToolKeyConfigList
>[number];
const getToolGroupText = (key: ToolGroupKey): string =>
({
[ToolGroupKey.SKILL]: I18n.t('bot_edit_type_skills'),
[ToolGroupKey.KNOWLEDGE]: I18n.t('bot_edit_type_knowledge'),
[ToolGroupKey.MEMORY]: I18n.t('bot_edit_type_memory'),
[ToolGroupKey.DIALOG]: I18n.t('bot_edit_type_dialog'),
[ToolGroupKey.CHARACTER]: I18n.t('bot_edit_type_character'),
[ToolGroupKey.HOOKS]: 'Hooks',
})[key];
const getToolText = (toolKey: ToolKey) =>
({
[ToolKey.PLUGIN]: I18n.t('Plugins'),
[ToolKey.WORKFLOW]: I18n.t('Workflows'),
[ToolKey.IMAGEFLOW]: I18n.t('imageflow_title'),
// 已废弃
[ToolKey.KNOWLEDGE]: '',
[ToolKey.VARIABLE]: I18n.t('user_profile'),
[ToolKey.DATABASE]: I18n.t('bot_database'),
[ToolKey.LONG_TERM_MEMORY]: I18n.t('timecapsule_1228_001'),
[ToolKey.FILE_BOX]: I18n.t('Starling_filebox_name'),
[ToolKey.TRIGGER]: I18n.t('platfrom_triggers_title'),
[ToolKey.ONBOARDING]: I18n.t('bot_preview_opening_remarks'),
[ToolKey.SUGGEST]: I18n.t('bot_edit_suggestion'),
[ToolKey.VOICE]: I18n.t('bot_edit_voices_title'),
[ToolKey.BACKGROUND]: I18n.t('bgi_title'),
[ToolKey.DOCUMENT]: I18n.t('dataset_detail_type_text'),
[ToolKey.TABLE]: I18n.t('dataset_detail_type_table'),
[ToolKey.PHOTO]: I18n.t('knowledge_photo_025'),
[ToolKey.SHORTCUT]: I18n.t('bot_ide_shortcut'),
[ToolKey.DEV_HOOKS]: 'Hooks',
[ToolKey.USER_INPUT]: I18n.t('chat_setting_user_input_default_mode'),
})[toolKey];
const AlertGroups: FC<{ items: AlertItem[] }> = ({ items }) => {
const grouped = groupBy(items, 'groupTitle');
return (
<div>
{Object.entries(grouped).map(([groupTitle, groupItems]) => (
<div
key={groupTitle}
className="py-[12px] flex items-center coz-stroke-primary border-0 border-solid border-b gap-5 pr-2"
>
<div className="min-w-[60px]">{groupTitle}</div>
<div className="flex-1 overflow-hidden whitespace-pre-line">
{groupItems.map(item => item.title).join(', ')}
</div>
</div>
))}
</div>
);
};
export interface AlertItem {
title: string;
groupTitle: string;
}
export const ModelCapabilityAlertModelContent: FC<{
notSupported: AlertItem[];
poorSupported: AlertItem[];
modelName: string;
onOk: () => void;
onCancel: () => void;
}> = ({ notSupported, poorSupported, modelName, onOk, onCancel }) => {
const [dontShowAgain, setDontShowAgain] = useState(false);
return (
<div>
<div className="flex items-center justify-between">
<div className="coz-fg-plus text-[20px]">
{I18n.t('confirm_switch_model')}
</div>
<IconButton
icon={<IconCozCross />}
onClick={onCancel}
theme="borderless"
/>
</div>
<div className="coz-fg-primary text-[14px]">
{I18n.t('model_support_poor_warning', {
modelName,
})}
</div>
{notSupported.length ? (
<div className="mt-[24px]">
<div className="coz-fg-secondary text-[12px]">
{I18n.t('model_not_supported')}
</div>
<AlertGroups items={notSupported} />
</div>
) : null}
{poorSupported.length ? (
<div className="mt-[24px]">
<div className="coz-fg-secondary text-[12px]">
{I18n.t('model_support_poor')}
</div>
<AlertGroups items={poorSupported} />
</div>
) : null}
<div className="mt-[24px] flex items-center justify-between">
<Checkbox onChange={e => setDontShowAgain(!!e.target.checked)}>
{I18n.t('do_not_remind_again')}
</Checkbox>
<Space>
<Button
color="primary"
onClick={() => {
onCancel();
}}
>
{I18n.t('Cancel')}
</Button>
<Button
color="brand"
onClick={() => {
if (dontShowAgain) {
localStorage.setItem(DONT_SHOW_TIPS_LOCAL_CACHE_KEY, 'true');
}
onOk();
}}
>
{I18n.t('Confirm')}
</Button>
</Space>
</div>
</div>
);
};
// TODO 统一封装 localStorage 服务,管理本地缓存的生命周期
export const DONT_SHOW_TIPS_LOCAL_CACHE_KEY =
'model_capability_check_do_not_show_again';
export const checkModelAbility = (
toolKeyConfigList: IRegisteredToolKeyConfig[],
config: NonNullable<Model['func_config']>,
): [AlertItem[], AlertItem[]] =>
toolKeyConfigList.reduce<[AlertItem[], AlertItem[]]>(
([notSupportedRes, poorSupportedRes], item) => {
const { hasValidData, toolKey, toolGroupKey } = item;
// 只在当前 tool 存在配置时才需要检查
if (hasValidData) {
const modelFunctionConfigType =
abilityKey2ModelFunctionConfigType(toolKey);
if (!modelFunctionConfigType) {
return [notSupportedRes, poorSupportedRes];
}
let modelFunctionConfigStatus = config[modelFunctionConfigType];
if (toolGroupKey === ToolGroupKey.KNOWLEDGE) {
const { auto } = useBotSkillStore.getState().knowledge.dataSetInfo;
if (toolGroupKey === ToolGroupKey.KNOWLEDGE) {
const autoConfigStatus =
config[
auto
? ModelFuncConfigType.KnowledgeAutoCall
: ModelFuncConfigType.KnowledgeOnDemandCall
];
modelFunctionConfigStatus = mergeModelFuncConfigStatus(
autoConfigStatus,
modelFunctionConfigStatus,
);
}
}
const alertItem: AlertItem = {
groupTitle: getToolGroupText(item.toolGroupKey),
title: item.toolTitle ?? getToolText(item.toolKey),
};
if (modelFunctionConfigStatus === ModelFuncConfigStatus.NotSupport) {
notSupportedRes.push(alertItem);
}
if (modelFunctionConfigStatus === ModelFuncConfigStatus.PoorSupport) {
poorSupportedRes.push(alertItem);
}
}
return [notSupportedRes, poorSupportedRes];
},
[[], []],
);
export const confirm = ({
notSupported,
poorSupported,
modelName,
}: {
notSupported: AlertItem[];
poorSupported: AlertItem[];
modelName: string;
}): Promise<boolean> => {
if (notSupported.length > 0 || poorSupported.length > 0) {
return new Promise(resolve => {
const modal = Modal.confirm({
header: null,
// 需要比模型配置的popover默认 z-index 1030 更高,这里进行内卷
zIndex: 1031,
mask: false,
width: 480,
onCancel: () => {
resolve(false);
modal.destroy();
},
content: (
<ModelCapabilityAlertModelContent
notSupported={notSupported}
poorSupported={poorSupported}
modelName={modelName}
onOk={() => {
resolve(true);
modal.destroy();
}}
onCancel={() => {
resolve(false);
modal.destroy();
}}
/>
),
footer: false,
showCancelButton: false,
});
});
}
return Promise.resolve(true);
};
const getGroupTittleByConfigType = (type: ModelFuncConfigType): string =>
({
[ModelFuncConfigType.Plugin]: I18n.t('bot_edit_type_skills'),
[ModelFuncConfigType.Workflow]: I18n.t('bot_edit_type_skills'),
[ModelFuncConfigType.ImageFlow]: I18n.t('bot_edit_type_skills'),
[ModelFuncConfigType.Trigger]: I18n.t('bot_edit_type_skills'),
[ModelFuncConfigType.KnowledgeText]: I18n.t('bot_edit_type_knowledge'),
[ModelFuncConfigType.KnowledgeTable]: I18n.t('bot_edit_type_knowledge'),
[ModelFuncConfigType.KnowledgePhoto]: I18n.t('bot_edit_type_knowledge'),
[ModelFuncConfigType.KnowledgeAutoCall]: I18n.t('bot_edit_type_knowledge'),
[ModelFuncConfigType.KnowledgeOnDemandCall]: I18n.t(
'bot_edit_type_knowledge',
),
[ModelFuncConfigType.Variable]: I18n.t('bot_edit_type_memory'),
[ModelFuncConfigType.Database]: I18n.t('bot_edit_type_memory'),
[ModelFuncConfigType.LongTermMemory]: I18n.t('bot_edit_type_memory'),
[ModelFuncConfigType.FileBox]: I18n.t('bot_edit_type_memory'),
[ModelFuncConfigType.Onboarding]: I18n.t('bot_edit_type_dialog'),
[ModelFuncConfigType.Suggestion]: I18n.t('bot_edit_type_dialog'),
[ModelFuncConfigType.ShortcutCommand]: I18n.t('bot_edit_type_dialog'),
[ModelFuncConfigType.BackGroundImage]: I18n.t('bot_edit_type_dialog'),
[ModelFuncConfigType.TTS]: I18n.t('bot_edit_type_character'),
[ModelFuncConfigType.MultiAgentRecognize]: I18n.t(
'agentflow_transfer_ conversation_settings_title',
),
[ModelFuncConfigType.HookInfo]: 'Hooks',
})[type];
const getTitleByConfigType = (type: ModelFuncConfigType): string =>
({
[ModelFuncConfigType.Plugin]: I18n.t('Plugins'),
[ModelFuncConfigType.Workflow]: I18n.t('Workflows'),
[ModelFuncConfigType.ImageFlow]: I18n.t('imageflow_title'),
[ModelFuncConfigType.Trigger]: I18n.t('platfrom_triggers_title'),
[ModelFuncConfigType.KnowledgeText]: I18n.t('dataset_detail_type_text'),
[ModelFuncConfigType.KnowledgeTable]: I18n.t('dataset_detail_type_table'),
[ModelFuncConfigType.KnowledgePhoto]: I18n.t('knowledge_photo_025'),
[ModelFuncConfigType.KnowledgeAutoCall]: I18n.t('dataset_automatic_call'),
[ModelFuncConfigType.KnowledgeOnDemandCall]: I18n.t(
'dataset_on_demand_call',
),
[ModelFuncConfigType.Variable]: I18n.t('user_profile'),
[ModelFuncConfigType.Database]: I18n.t('bot_database'),
[ModelFuncConfigType.LongTermMemory]: I18n.t('timecapsule_1228_001'),
[ModelFuncConfigType.FileBox]: I18n.t('Starling_filebox_name'),
[ModelFuncConfigType.Onboarding]: I18n.t('bot_preview_opening_remarks'),
[ModelFuncConfigType.Suggestion]: I18n.t('bot_edit_suggestion'),
[ModelFuncConfigType.ShortcutCommand]: I18n.t('bot_ide_shortcut'),
[ModelFuncConfigType.BackGroundImage]: I18n.t('bgi_title'),
[ModelFuncConfigType.TTS]: I18n.t('bot_edit_voices_title'),
[ModelFuncConfigType.MultiAgentRecognize]: I18n.t(
'agentflow_transfer_ conversation_settings_mode_node_title',
),
[ModelFuncConfigType.HookInfo]: 'Hooks',
})[type];
export const mapConfigTypeToAlertItem = (
type: ModelFuncConfigType,
): AlertItem => ({
groupTitle: getGroupTittleByConfigType(type),
title: getTitleByConfigType(type),
});

View File

@@ -0,0 +1,288 @@
/*
* 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, useState } from 'react';
import { useRegisteredToolKeyConfigList } from '@coze-agent-ide/tool';
import { type Agent } from '@coze-studio/bot-detail-store';
import { useBotEditor } from '@coze-agent-ide/bot-editor-context-store';
import { Modal } from '@coze-arch/coze-design';
import { agentModelFuncConfigCheck } from '../../utils/model-func-config-check/agent-check';
import {
checkModelAbility,
DONT_SHOW_TIPS_LOCAL_CACHE_KEY,
confirm,
ModelCapabilityAlertModelContent,
mapConfigTypeToAlertItem,
type AlertItem,
} from './base';
export const useModelCapabilityCheckAndConfirm = () => {
const toolKeyConfigList = useRegisteredToolKeyConfigList();
const {
storeSet: { useModelStore },
} = useBotEditor();
return async (modelId: string): Promise<boolean> => {
if (localStorage.getItem(DONT_SHOW_TIPS_LOCAL_CACHE_KEY)) {
return true;
}
const model = useModelStore.getState().getModelById(modelId);
const functionConfig = model?.func_config;
if (!functionConfig) {
return true;
}
const [notSupported, poorSupported] = checkModelAbility(
toolKeyConfigList,
functionConfig,
);
return confirm({
notSupported,
poorSupported,
modelName: model.name ?? '',
});
};
};
export function useModelCapabilityCheckModal({
onOk,
}: {
/** 当新模型不满足配置时会自动弹窗确认,该参数是确认弹窗的回调 */
onOk: (modelId: string) => void;
}): {
modalNode: ReactNode;
/**
* 检查新模型能力是否满足配置,不满足的话会自动弹出确认 modal
* @returns true 代表满足
*/
checkAndOpenModal: (modelId: string) => boolean;
} {
const toolKeyConfigList = useRegisteredToolKeyConfigList();
const {
storeSet: { useModelStore },
} = useBotEditor();
const [modalVisible, setModalVisible] = useState(false);
// Q单纯通过 modalState 是不是就能判断 modal 是否需要展示modalVisible 有点多余?
// A这里将 visible 和 state 拆分是为了避免弹窗在关闭动画期间 state 数据变更导致弹窗内容跳变
const [modalState, setModalState] = useState<
| {
notSupported: AlertItem[];
poorSupported: AlertItem[];
modelName: string;
modelId: string;
}
| undefined
>();
return {
modalNode: modalState ? (
<Modal
visible={modalVisible}
// 需要比模型配置的popover默认 z-index 1030 更高,这里进行内卷
zIndex={1031}
width={480}
header={null}
footer={null}
onCancel={() => setModalVisible(false)}
>
<ModelCapabilityAlertModelContent
notSupported={modalState.notSupported}
poorSupported={modalState.poorSupported}
modelName={modalState.modelName}
onOk={() => {
onOk(modalState.modelId);
setModalVisible(false);
}}
onCancel={() => {
setModalVisible(false);
}}
/>
</Modal>
) : null,
checkAndOpenModal: modelId => {
if (localStorage.getItem(DONT_SHOW_TIPS_LOCAL_CACHE_KEY)) {
return true;
}
const model = useModelStore.getState().getModelById(modelId);
const functionConfig = model?.func_config;
if (!functionConfig) {
return true;
}
const [notSupported, poorSupported] = checkModelAbility(
toolKeyConfigList,
functionConfig,
);
if (notSupported.length === 0 && poorSupported.length === 0) {
return true;
}
setModalVisible(true);
setModalState({
notSupported,
poorSupported,
modelId,
modelName: model?.name ?? '',
});
return false;
},
};
}
export const useAgentModelCapabilityCheckAndAlert = () => {
const toolKeyConfigList = useRegisteredToolKeyConfigList();
const {
storeSet: { useModelStore, useDraftBotDataSetStore },
} = useBotEditor();
return async (modelId: string, agent: Agent) => {
if (localStorage.getItem(DONT_SHOW_TIPS_LOCAL_CACHE_KEY)) {
return true;
}
const model = useModelStore.getState().getModelById(modelId);
const config = model?.func_config;
if (!config) {
return true;
}
const [commonNotSupported, commonPoorSupported] = checkModelAbility(
toolKeyConfigList,
config,
);
const { notSupported, poorSupported } = agentModelFuncConfigCheck({
config,
agent,
context: {
getDatasetById: id =>
useDraftBotDataSetStore.getState().datasetsMap[id],
config,
},
});
return confirm({
notSupported: [
...commonNotSupported,
...notSupported.map(mapConfigTypeToAlertItem),
],
poorSupported: [
...commonPoorSupported,
...poorSupported.map(mapConfigTypeToAlertItem),
],
modelName: model.name ?? '',
});
};
};
export function useAgentModelCapabilityCheckModal({
onOk,
}: {
/** 当新模型不满足配置时会自动弹窗确认,该参数是确认弹窗的回调 */
onOk: (modelId: string) => void;
}): {
modalNode: ReactNode;
/**
* 检查新模型能力是否满足配置,不满足的话会自动弹出确认 modal
* @returns true 代表满足
*/
checkAndOpenModal: (modelId: string, agent: Agent) => boolean;
} {
const toolKeyConfigList = useRegisteredToolKeyConfigList();
const {
storeSet: { useModelStore, useDraftBotDataSetStore },
} = useBotEditor();
const [modalVisible, setModalVisible] = useState(false);
// Q单纯通过 modalState 是不是就能判断 modal 是否需要展示modalVisible 有点多余?
// A这里将 visible 和 state 拆分是为了避免弹窗在关闭动画期间 state 数据变更导致弹窗内容跳变
const [modalState, setModalState] = useState<
| {
notSupported: AlertItem[];
poorSupported: AlertItem[];
modelName: string;
modelId: string;
}
| undefined
>();
return {
modalNode: modalState ? (
<Modal
visible={modalVisible}
// 需要比模型配置的popover默认 z-index 1030 更高,这里进行内卷
zIndex={1031}
width={480}
header={null}
footer={null}
onCancel={() => setModalVisible(false)}
>
<ModelCapabilityAlertModelContent
notSupported={modalState.notSupported}
poorSupported={modalState.poorSupported}
modelName={modalState.modelName}
onOk={() => {
onOk(modalState.modelId);
setModalVisible(false);
}}
onCancel={() => {
setModalVisible(false);
}}
/>
</Modal>
) : null,
checkAndOpenModal: (modelId, agent) => {
if (localStorage.getItem(DONT_SHOW_TIPS_LOCAL_CACHE_KEY)) {
return true;
}
const model = useModelStore.getState().getModelById(modelId);
const config = model?.func_config;
if (!config) {
return true;
}
const [commonNotSupported, commonPoorSupported] = checkModelAbility(
toolKeyConfigList,
config,
);
const { notSupported, poorSupported } = agentModelFuncConfigCheck({
config,
agent,
context: {
getDatasetById: id =>
useDraftBotDataSetStore.getState().datasetsMap[id],
config,
},
});
if (notSupported.length === 0 && poorSupported.length === 0) {
return true;
}
setModalVisible(true);
setModalState({
notSupported: [
...commonNotSupported,
...notSupported.map(mapConfigTypeToAlertItem),
],
poorSupported: [
...commonPoorSupported,
...poorSupported.map(mapConfigTypeToAlertItem),
],
modelId,
modelName: model?.name ?? '',
});
return false;
},
};
}

View File

@@ -0,0 +1,55 @@
.form-item {
margin-bottom: 18px;
.field-content {
display: flex;
align-items: center;
justify-content: space-between;
&:last-child {
margin-bottom: 0;
}
}
.label-content {
display: flex;
flex-shrink: 0;
column-gap: 4px;
align-items: center;
font-size: 12px;
font-weight: 500;
line-height: 16px;
/* stylelint-disable-next-line custom-property-pattern */
color: var(--Fg-Primary-COZ_fg_secondary, rgba(6, 7, 9, 50%));
.label {
flex-shrink: 0;
}
svg {
width: 12px;
height: 12px;
}
}
.field-main {
width: 280px;
:global(.semi-select) {
width: 100%;
}
}
.icon {
color: #6B6D75;
}
.field-feedback {
margin-top: 12px;
font-size: 12px;
@apply coz-fg-secondary;
}
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type PropsWithChildren, type ReactNode } from 'react';
import { useField } from '@formily/react';
import { type Field } from '@formily/core';
import { Popover } from '@coze-arch/bot-semi';
import { MdBoxLazy } from '@coze-arch/bot-md-box-adapter/lazy';
import { IconInfo } from '@coze-arch/bot-icons';
import commonStyles from '../index.module.less';
import styles from './index.module.less';
export interface ModelFormItemProps {
label: ReactNode | undefined;
popoverContent: string | undefined;
}
export const ModelFormItem: React.FC<PropsWithChildren<ModelFormItemProps>> = ({
label,
popoverContent,
children,
}) => {
const field = useField<Field>();
return (
<div className={styles['form-item']}>
<div className={styles['field-content']}>
<label className={styles['label-content']}>
<span className={styles.label}>{label}</span>
{popoverContent ? (
<Popover
className={commonStyles.popover}
showArrow
arrowPointAtCenter
content={
<MdBoxLazy
markDown={popoverContent}
autoFixSyntax={{ autoFixEnding: false }}
/>
}
>
<IconInfo className={styles.icon} />
</Popover>
) : null}
</label>
<div className={styles['field-main']}>{children}</div>
</div>
{field?.feedbacks?.map((feedback, index) => (
<p key={index} className={styles['field-feedback']}>
{feedback.messages}
</p>
))}
</div>
);
};

View File

@@ -0,0 +1,75 @@
.content {
width: 100%;
padding-bottom: 18px;
}
.group {
width: 100%;
padding-top: 12px;
&:last-child {
.content {
padding-bottom: 0;
}
}
}
.group-with-collapse {
padding-bottom: 16px;
}
.generation-diversity-group {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
margin-bottom: 8px;
}
.collapse {
width: 100%;
}
.group-label {
margin-bottom: 4px;
font-size: 14px;
font-weight: 500;
line-height: 20px;
/* stylelint-disable-next-line custom-property-pattern */
color: var(--Fg-Primary-COZ_fg_plus, rgba(6, 7, 9, 96%));
}
.diversity-label {
display: flex;
flex-shrink: 0;
column-gap: 8px;
align-items: center;
align-self: flex-start;
.icon {
font-size: 14px;
// 转换自 color: #060709" opacity="0.5"
color: #828384;
}
}
.rotate {
svg {
transform: rotate(180deg);
}
}
.advance {
align-self: flex-end;
}
.radio-group {
align-self: center;
width: 100%;
margin-bottom: 8px;
}

View File

@@ -0,0 +1,201 @@
/*
* 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 PropsWithChildren,
type ReactNode,
} from 'react';
import { nanoid } from 'nanoid';
import classNames from 'classnames';
import { useBotDetailIsReadonly } from '@coze-studio/bot-detail-store';
import { I18n } from '@coze-arch/i18n';
import { IconCozArrowDown } from '@coze-arch/coze-design/icons';
import { Button, Collapsible } from '@coze-arch/coze-design';
import { Popover } from '@coze-arch/bot-semi';
import { MdBoxLazy } from '@coze-arch/bot-md-box-adapter/lazy';
import { IconInfo } from '@coze-arch/bot-icons';
import { ModelStyle } from '@coze-arch/bot-api/developer_api';
import { PresetRadioGroup } from '../preset-radio-group';
import commonStyles from '../index.module.less';
import { useModelForm } from '../../../context/model-form-context';
import {
type FormilyCoreType,
type FormilyReactType,
} from '../../../context/formily-context/type';
import { useFormily } from '../../../context/formily-context';
import styles from './index.module.less';
export interface ModelFormGroupItemProps {
title: ReactNode | undefined;
}
export const ModelFormGroupItem: React.FC<
PropsWithChildren<ModelFormGroupItemProps>
> = ({ title, children }) => (
<div className={styles.group}>
<div className={classNames(styles['group-label'], styles.title)}>
{title}
</div>
<div className={styles.content}>{children}</div>
</div>
);
type DiversityGroupItemImplProps = ModelFormGroupItemProps & {
disabled: boolean;
formilyReact: FormilyReactType;
formilyCore: FormilyCoreType;
};
const DiversityGroupItemImpl: React.FC<
PropsWithChildren<DiversityGroupItemImplProps>
> = ({ formilyCore, formilyReact, title, children, disabled }) => {
const form = formilyReact.useForm();
const {
isGenerationDiversityOpen,
setGenerationDiversityOpen,
hideDiversityCollapseButton,
} = useModelForm();
const [modelStyle, setModelStyle] = useState<ModelStyle>(
form.values.model_style,
);
const toggleOpen = () => {
setGenerationDiversityOpen(!isGenerationDiversityOpen);
};
const handleValuesChange = (changedValue: ModelStyle) => {
form.setValues({ model_style: changedValue });
};
useEffect(() => {
const effectId = nanoid();
form.addEffects(effectId, () => {
formilyCore.onFormValuesChange(localeForm => {
setModelStyle(localeForm.values.model_style);
});
});
return () => {
form.removeEffects(effectId);
};
}, [form]);
/**
* 这里区分一下初始化和后续操作
* 只读状态时 customize 也默认收起否则默认展开
* 当然只读状态无法修改 model_style
*/
useEffect(() => {
if (disabled) {
return;
}
if (modelStyle !== ModelStyle.Custom) {
return;
}
setGenerationDiversityOpen(true);
}, [modelStyle]);
return (
<div
className={classNames(
styles.group,
!isGenerationDiversityOpen && styles['group-with-collapse'],
)}
>
<div className={styles['generation-diversity-group']}>
<div
className={classNames(
styles['group-label'],
styles['diversity-label'],
)}
>
<div>{title}</div>
<Popover
className={commonStyles.popover}
showArrow
arrowPointAtCenter
content={
<MdBoxLazy
markDown={I18n.t('model_config_generate_explain')}
autoFixSyntax={{ autoFixEnding: false }}
/>
}
>
<IconInfo className={styles.icon} />
</Popover>
</div>
<PresetRadioGroup
className={styles['radio-group']}
disabled={disabled}
value={modelStyle}
onChange={handleValuesChange}
/>
{hideDiversityCollapseButton ? null : (
<Button
onClick={toggleOpen}
iconPosition="right"
icon={<IconCozArrowDown />}
className={classNames(
isGenerationDiversityOpen && styles.rotate,
styles.advance,
)}
color="secondary"
>
{I18n.t('model_config_generate_advance')}
</Button>
)}
</div>
<Collapsible
isOpen={isGenerationDiversityOpen}
className={styles.collapse}
>
<div className={styles.content}>{children}</div>
</Collapsible>
</div>
);
};
export const ModelFormGenerationDiversityGroupItem: React.FC<
PropsWithChildren<ModelFormGroupItemProps>
> = props => {
const {
formilyModule: { formilyReact, formilyCore },
} = useFormily();
const isReadonly = useBotDetailIsReadonly();
if (!formilyReact || !formilyCore) {
return null;
}
return (
<DiversityGroupItemImpl
formilyCore={formilyCore}
formilyReact={formilyReact}
disabled={isReadonly}
{...props}
/>
);
};

View File

@@ -0,0 +1,48 @@
.model-select-wrapper {
display: flex;
flex-direction: column;
row-gap: 4px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 18px;
>span {
font-size: 16px;
font-weight: 500;
font-style: normal;
line-height: 24px;
/* stylelint-disable-next-line custom-property-pattern */
color: var(--Fg-Primary-COZ_fg_plus);
}
}
.popover {
max-width: 224px;
padding: 8px 12px;
}
.error-state {
display: flex;
column-gap: 8px;
align-items: center;
height: 100%;
font-size: 14px;
.fail-page-icon {
color: #FF2710;
}
.fail-page-text {
font-weight: 600;
line-height: 22px;
}
.fail-page-retry {
cursor: pointer;
color: #4D53E8;
}
}

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 { useEffect } from 'react';
import { useCreation } from 'ahooks';
import { type ISchema } from '@formily/react';
import { type Form } from '@formily/core';
import { I18n } from '@coze-arch/i18n';
import { IconCozWarningCircleFill } from '@coze-arch/coze-design/icons';
import { Loading } from '@coze-arch/coze-design';
import { primitiveExhaustiveCheck } from '../../utils/exhaustive-check';
import {
type FormilyReactType,
type FormilyCoreType,
} from '../../context/formily-context/type';
import { useFormily } from '../../context/formily-context';
import { modelFormComponentMap } from './type';
import { ModelSelect } from './model-select';
import styles from './index.module.less';
export interface ModelFormProps {
currentModelId: string | undefined;
onModelChange: (value: string) => void;
onFormInit: (form: Form, formilyCore: FormilyCoreType) => void;
onFormUnmount: () => void;
schema: ISchema | undefined;
}
interface ModelFormImplProps extends ModelFormProps {
formilyCore: FormilyCoreType;
formilyReact: FormilyReactType;
}
const ModelFormImpl: React.FC<ModelFormImplProps> = ({
formilyCore,
// eslint-disable-next-line @typescript-eslint/naming-convention -- 此规则不适用这个 case
formilyReact: { createSchemaField, FormProvider },
currentModelId,
onModelChange,
onFormInit,
onFormUnmount,
schema,
}) => {
const { createForm } = formilyCore;
const form = useCreation(() => createForm(), [currentModelId]);
const SchemaField = useCreation(
() => createSchemaField({ components: modelFormComponentMap }),
[],
);
useEffect(() => {
// 在 promise executor 中执行回调,其中的错误会异步产生 promise rejection ,而不是导致页面白屏
new Promise(() => onFormInit(form, formilyCore));
return onFormUnmount;
}, [form]);
return (
<FormProvider form={form}>
<div>
<div className={styles['model-select-wrapper']}>
<span>{I18n.t('model_config_model')}</span>
<ModelSelect value={currentModelId} onChange={onModelChange} />
</div>
<SchemaField schema={schema} />
</div>
</FormProvider>
);
};
export const ModelForm: React.FC<ModelFormProps> = ({
currentModelId,
onModelChange,
onFormInit,
schema,
onFormUnmount,
}) => {
const { formilyModule, retryImportFormily } = useFormily();
if (formilyModule.status === 'loading' || formilyModule.status === 'unInit') {
return <Loading loading />;
}
if (formilyModule.status === 'error') {
return (
<div className={styles['error-state']}>
<IconCozWarningCircleFill className={styles['fail-page-icon']} />
<div className={styles['fail-page-text']}>
<span>{I18n.t('model_form_fail_text')}</span>
<span
className={styles['fail-page-retry']}
onClick={retryImportFormily}
>
{I18n.t('model_form_fail_retry')}
</span>
</div>
</div>
);
}
if (formilyModule.status === 'ready') {
const { formilyCore, formilyReact } = formilyModule;
return (
<ModelFormImpl
schema={schema}
formilyCore={formilyCore}
formilyReact={formilyReact}
currentModelId={currentModelId}
onModelChange={onModelChange}
onFormInit={onFormInit}
onFormUnmount={onFormUnmount}
/>
);
}
primitiveExhaustiveCheck(formilyModule.status);
return null;
};

View File

@@ -0,0 +1,45 @@
.label-content {
overflow: hidden;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-left: 8px;
}
.model-content {
overflow: hidden;
display: flex;
column-gap: 8px;
align-items: center;
width: 100%;
.model-content-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
}
}
.select {
width: 100%;
:global(.semi-select-content-wrapper) {
width: 100%;
}
}
.model-token {
flex-shrink: 0;
}
.model-name {
overflow: hidden;
display: inline-block;
text-overflow: ellipsis;
white-space: nowrap;
}

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, { type CSSProperties } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { type Model } from '@coze-arch/bot-api/developer_api';
import { useBotDetailIsReadonly } from '@coze-studio/bot-detail-store';
import { useBotEditor } from '@coze-agent-ide/bot-editor-context-store';
import { UIModelSelect } from './ui-model-select';
const getModelOptionList = ({
onlineModelList,
offlineModelMap,
currentModelId,
}: {
onlineModelList: Model[];
offlineModelMap: Record<string, Model>;
currentModelId: string | undefined;
}) => {
if (!currentModelId) {
return onlineModelList;
}
const specialModel = offlineModelMap[currentModelId];
if (!specialModel) {
return onlineModelList;
}
return onlineModelList.concat([specialModel]);
};
export interface ModelSelectProps {
className?: string;
style?: CSSProperties;
value: string | undefined;
onChange: (value: string) => void;
}
export const ModelSelect: React.FC<ModelSelectProps> = ({
value,
...restProps
}) => {
const {
storeSet: { useModelStore },
} = useBotEditor();
const { onlineModelList, offlineModelMap } = useModelStore(
useShallow(state => ({
onlineModelList: state.onlineModelList,
offlineModelMap: state.offlineModelMap,
})),
);
const isReadonly = useBotDetailIsReadonly();
// 用户从特殊模型切换到正常模型后, 可选项列表将发生变化,于是用户再也切换不回去了
const modelList = getModelOptionList({
onlineModelList,
offlineModelMap,
currentModelId: value,
});
return (
<UIModelSelect
modelList={modelList}
disabled={isReadonly}
value={value}
{...restProps}
/>
);
};

View File

@@ -0,0 +1,147 @@
/*
* 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, { useState, type CSSProperties } from 'react';
import { groupBy } from 'lodash-es';
import classNames from 'classnames';
import { ModelOptionItem } from '@coze-studio/components';
import { Select } from '@coze-arch/coze-design';
import { type OptionProps } from '@coze-arch/bot-semi/Select';
import { type ModelTag, type Model } from '@coze-arch/bot-api/developer_api';
import { getModelClassSortList } from '../../../utils/model/get-model-class-sort-list';
import styles from './index.module.less';
export interface UIModelSelectProps {
className?: string;
style?: CSSProperties;
value: string | undefined;
onChange?: (value: string) => void;
modelList: Model[];
disabled?: boolean;
}
export const UIModelSelect: React.FC<UIModelSelectProps> = ({
className,
style,
value,
onChange,
modelList = [],
disabled = false,
}) => {
const [inputValue, setInputValue] = useState('');
// 专业版有项目维度区分classmodal_class 与 modal_class_name存在一对多的情况因此统一以model_class_name做分组
// 以 modal_class_name 首次出现的顺序进行排序
const modelClassSortList = getModelClassSortList(
modelList.map(i => i.model_class_name ?? ''),
);
const modelClassGroup = groupBy(modelList, model => model.model_class_name);
const showEndPointName = modelList.some(model => model.endpoint_name);
// 搜索规则: 模型名称/接入点名称包含关键词(不区分大小写)
const filterOption = (model: Model) => {
const sugInput = inputValue.toUpperCase();
return (
model.name?.toUpperCase()?.includes(sugInput) ||
model?.endpoint_name?.toUpperCase()?.includes(sugInput)
);
};
const getModelOptionLabel = (model: Model) => (
<ModelOptionItem
key={model.model_type}
tokenLimit={model.model_quota?.token_limit}
avatar={model.model_icon}
name={model.name}
descriptionGroupList={model.model_desc}
searchWords={[inputValue]}
endPointName={model?.endpoint_name}
showEndPointName={showEndPointName}
tags={model.model_tag_list
?.filter(
(item): item is ModelTag & Required<Pick<ModelTag, 'tag_name'>> =>
!!item.tag_name,
)
.map(item => ({
label: item.tag_name,
color: 'yellow',
}))}
/>
);
const optionsList = modelClassSortList
.map(stringClassId => {
const groupMemberList = modelClassGroup[stringClassId];
return {
label: groupMemberList?.at(0)?.model_class_name,
children: groupMemberList
?.filter(model => filterOption(model))
?.map(model => ({
label: getModelOptionLabel(model),
value: model.model_type?.toString(),
})),
};
})
.map(group => (
// 修改key原因详见https://semi.design/zh-CN/input/select - 分组模块
// 1. 分组能力只能使用jsx
// 2. 若Select的children需要动态更新OptGroup上的key也需要进行更新否则Select无法识别
<Select.OptGroup key={`${inputValue}-${group.label}`} label={group.label}>
{group.children?.map(option => (
<Select.Option value={option.value} key={option.value}>
{option.label}
</Select.Option>
))}
</Select.OptGroup>
));
if (modelList.length === 1) {
const targetModel = modelList.at(0);
return targetModel ? getModelOptionLabel(targetModel) : null;
}
const renderSelectedItem = (optionNode: OptionProps) =>
React.isValidElement(optionNode?.children) ? (
<ModelOptionItem
{...optionNode?.children?.props}
showEndPointName={false}
/>
) : null;
return (
<Select
clickToHide
disabled={disabled}
className={classNames(styles.select, className)}
style={style}
value={value}
dropdownClassName="max-w-[432px]"
onChange={changedValue => {
if (typeof changedValue === 'string') {
onChange?.(changedValue);
}
}}
onSearch={v => setInputValue(v)}
filter
renderSelectedItem={renderSelectedItem}
>
{optionsList}
</Select>
);
};

View File

@@ -0,0 +1,13 @@
.button-radio {
display: flex;
:global {
.semi-radio {
flex: 1;
.semi-radio-content {
width: 100%;
}
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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 classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { RadioGroup } from '@coze-arch/bot-semi';
import { ModelStyle } from '@coze-arch/bot-api/developer_api';
import styles from './index.module.less';
export interface PresetRadioOption {
label: string;
value: ModelStyle;
}
const getOptions: () => PresetRadioOption[] = () => [
{ label: I18n.t('model_config_generate_precise'), value: ModelStyle.Precise },
{ label: I18n.t('model_config_generate_balance'), value: ModelStyle.Balance },
{
label: I18n.t('model_config_generate_creative'),
value: ModelStyle.Creative,
},
{
label: I18n.t('model_config_generate_customize'),
value: ModelStyle.Custom,
},
];
export interface PresetRadioGroupProps {
onChange: (value: ModelStyle) => void;
value: ModelStyle;
className?: string;
style?: CSSProperties;
disabled?: boolean;
}
export const PresetRadioGroup: React.FC<PresetRadioGroupProps> = ({
onChange,
className,
style,
value,
disabled,
}) => (
<RadioGroup
disabled={disabled}
className={classNames(styles['button-radio'], className)}
style={style}
options={getOptions()}
value={value}
onChange={e => {
onChange(e.target.value);
}}
type="button"
/>
);

View File

@@ -0,0 +1,59 @@
/*
* 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 JSXComponent } from '@formily/react';
import { InputSlider, type InputSliderProps } from '@coze-studio/components';
import {
Switch,
RadioGroup,
type RadioGroupProps,
} from '@coze-arch/coze-design';
import { UIInput } from '@coze-arch/bot-semi';
import {
ModelFormComponent,
ModelFormVoidFieldComponent,
} from '../../constant/model-form-component';
import {
ModelFormGenerationDiversityGroupItem,
ModelFormGroupItem,
} from './group-item';
import { ModelFormItem, type ModelFormItemProps } from './form-item';
export const modelFormComponentMap: Record<
ModelFormComponent | ModelFormVoidFieldComponent,
JSXComponent
> = {
[ModelFormComponent.Input]: UIInput,
[ModelFormComponent.RadioButton]: RadioGroup,
[ModelFormComponent.Switch]: Switch,
[ModelFormComponent.SliderInputNumber]: InputSlider,
[ModelFormComponent.ModelFormItem]: ModelFormItem,
[ModelFormVoidFieldComponent.ModelFormGroupItem]: ModelFormGroupItem,
[ModelFormVoidFieldComponent.ModelFormGenerationDiversityGroupItem]:
ModelFormGenerationDiversityGroupItem,
};
export interface ModelFormComponentPropsMap {
[ModelFormComponent.Input]: Record<string, never>;
[ModelFormComponent.RadioButton]: Pick<RadioGroupProps, 'options' | 'type'>;
[ModelFormComponent.Switch]: Record<string, never>;
[ModelFormComponent.SliderInputNumber]: Pick<
InputSliderProps,
'min' | 'max' | 'step' | 'decimalPlaces'
>;
[ModelFormComponent.ModelFormItem]: ModelFormItemProps;
}

View File

@@ -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';

View File

@@ -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',
},
};

View File

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

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

View File

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

View File

@@ -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)`;
}

View File

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

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.
*/
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}
/>
);
}

View File

@@ -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');
}

View File

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

View File

@@ -0,0 +1,6 @@
.form-wrapper {
position: relative;
box-sizing: content-box;
width: 432px;
margin-bottom: 24px;
}

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 { useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useMultiAgentStore } from '@coze-studio/bot-detail-store/multi-agent';
import { useModelStore } from '@coze-studio/bot-detail-store/model';
import { useBotDetailIsReadonly } from '@coze-studio/bot-detail-store';
import { CustomError } from '@coze-arch/bot-error';
import { useBotEditor } from '@coze-agent-ide/bot-editor-context-store';
import { ModelForm } from '../../model-form';
import { useAgentModelCapabilityCheckAndAlert } from '../../model-capability-confirm-model';
import { ReportEventNames } from '../../../report-events/report-event-names';
import { useHandleModelForm } from '../../../hooks/model-form/use-handle-model-form';
import styles from './index.module.less';
export const MultiAgentModelForm: React.FC<{ agentId: string }> = ({
agentId,
}) => {
const { agent, setMultiAgentByImmer } = useMultiAgentStore(
useShallow(state => ({
agent: state.agents.find(item => item.id === agentId),
setMultiAgentByImmer: state.setMultiAgentByImmer,
})),
);
const { ShortMemPolicy } = useModelStore(
useShallow(state => ({
ShortMemPolicy: state.config.ShortMemPolicy,
})),
);
const { storeSet } = useBotEditor();
const modelStore = storeSet.useModelStore(
useShallow(state => ({
onlineModelList: state.onlineModelList,
offlineModelMap: state.offlineModelMap,
getModelPreset: state.getModelPreset,
})),
);
const isReadonly = useBotDetailIsReadonly();
if (!agent) {
throw new CustomError(
ReportEventNames.FailedGetAgentById,
`agentId: ${agentId}`,
);
}
const [modelId, setModelId] = useState(agent.model.model ?? '');
const { getSchema, handleFormInit, handleFormUnmount } = useHandleModelForm({
currentModelId: modelId,
editable: !isReadonly,
getModelRecord: () => agent.model,
onValuesChange: ({ values }) => {
setMultiAgentByImmer(({ agents }) => {
const target = agents?.find(item => item.id === agentId);
if (!target) {
return;
}
target.model = {
model: modelId,
...values,
ShortMemPolicy,
};
});
},
modelStore,
});
const schema = useMemo(
() =>
getSchema({
currentModelId: modelId,
isSingleAgent: false,
}),
[modelId],
);
const checkAndAlert = useAgentModelCapabilityCheckAndAlert();
return (
<div className={styles['form-wrapper']}>
<ModelForm
schema={schema}
currentModelId={modelId}
onModelChange={async newId => {
const res = await checkAndAlert(newId, agent);
if (res) {
setModelId(newId);
}
}}
onFormInit={handleFormInit}
onFormUnmount={handleFormUnmount}
/>
</div>
);
};

View File

@@ -0,0 +1,17 @@
.form-wrapper {
position: relative;
box-sizing: content-box;
width: 432px;
padding: 24px;
}
.form-title {
height: 32px;
margin-bottom: 12px;
font-size: 16px;
font-weight: 500;
line-height: 32px;
/* stylelint-disable-next-line custom-property-pattern */
color: var(--Fg-Primary-COZ_fg_plus);
}

View File

@@ -0,0 +1,96 @@
/*
* 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, useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { I18n } from '@coze-arch/i18n';
import { useModelStore } from '@coze-studio/bot-detail-store/model';
import { useBotDetailIsReadonly } from '@coze-studio/bot-detail-store';
import { useBotEditor } from '@coze-agent-ide/bot-editor-context-store';
import { ModelForm } from '../model-form';
import { useHandleModelForm } from '../../hooks/model-form/use-handle-model-form';
import styles from './index.module.less';
export const SingleAgentModelForm: FC<{
onBeforeSwitchModel?: (modelId: string) => Promise<boolean>;
}> = ({ onBeforeSwitchModel }) => {
const { model, setModelByImmer } = useModelStore(
useShallow(state => ({
model: state,
setModelByImmer: state.setModelByImmer,
})),
);
const { storeSet } = useBotEditor();
const modelStore = storeSet.useModelStore(
useShallow(state => ({
onlineModelList: state.onlineModelList,
offlineModelMap: state.offlineModelMap,
getModelPreset: state.getModelPreset,
})),
);
const isReadonly = useBotDetailIsReadonly();
const [modelId, setModelId] = useState(model.config.model ?? '');
const { getSchema, handleFormInit, handleFormUnmount } = useHandleModelForm({
currentModelId: modelId,
editable: !isReadonly,
getModelRecord: () => model.config,
onValuesChange: ({ values }) => {
setModelByImmer(draft => {
draft.config = {
model: modelId,
...values,
};
});
},
modelStore,
});
const schema = useMemo(
() =>
getSchema({
currentModelId: modelId,
isSingleAgent: true,
}),
[modelId],
);
return (
<div
className={styles['form-wrapper']}
data-testid="bot.ide.bot_creator.model_config_form"
>
<div className={styles['form-title']}>{I18n.t('model_config_title')}</div>
<ModelForm
schema={schema}
currentModelId={modelId}
onModelChange={async newId => {
const res = onBeforeSwitchModel
? await onBeforeSwitchModel(newId)
: true;
if (res) {
setModelId(newId);
}
}}
onFormInit={handleFormInit}
onFormUnmount={handleFormUnmount}
/>
</div>
);
};