Compare commits

..

1 Commits

Author SHA1 Message Date
Stephen Zhou
b72fc07006 修复 Toast 类型检查} (Wait need close?)}{ 2026-03-06 19:20:00 +08:00
164 changed files with 2371 additions and 7248 deletions

View File

@@ -63,12 +63,7 @@ class RagPipelineTransformService:
):
node = self._deal_file_extensions(node)
if node.get("data", {}).get("type") == "knowledge-index":
knowledge_configuration = KnowledgeConfiguration.model_validate(node.get("data", {}))
if dataset.tenant_id != current_user.current_tenant_id:
raise ValueError("Unauthorized")
node = self._deal_knowledge_index(
knowledge_configuration, dataset, indexing_technique, retrieval_model, node
)
node = self._deal_knowledge_index(dataset, doc_form, indexing_technique, retrieval_model, node)
new_nodes.append(node)
if new_nodes:
graph["nodes"] = new_nodes
@@ -160,13 +155,14 @@ class RagPipelineTransformService:
def _deal_knowledge_index(
self,
knowledge_configuration: KnowledgeConfiguration,
dataset: Dataset,
doc_form: str,
indexing_technique: str | None,
retrieval_model: RetrievalSetting | None,
node: dict,
):
knowledge_configuration_dict = node.get("data", {})
knowledge_configuration = KnowledgeConfiguration.model_validate(knowledge_configuration_dict)
if indexing_technique == "high_quality":
knowledge_configuration.embedding_model = dataset.embedding_model

View File

@@ -295,7 +295,24 @@ describe('Pricing Modal Flow', () => {
})
})
// ─── 6. Pricing URL ─────────────────────────────────────────────────────
// ─── 6. Close Handling ───────────────────────────────────────────────────
describe('Close handling', () => {
it('should call onCancel when pressing ESC key', () => {
render(<Pricing onCancel={onCancel} />)
// ahooks useKeyPress listens on document for keydown events
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
bubbles: true,
}))
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
// ─── 7. Pricing URL ─────────────────────────────────────────────────────
describe('Pricing page URL', () => {
it('should render pricing link with correct URL', () => {
render(<Pricing onCancel={onCancel} />)

View File

@@ -160,7 +160,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
isShow={isShowDeleteConfirm}
onClose={() => setIsShowDeleteConfirm(false)}
>
<div className="mb-3 text-text-primary title-2xl-semi-bold">{t('avatar.deleteTitle', { ns: 'common' })}</div>
<div className="title-2xl-semi-bold mb-3 text-text-primary">{t('avatar.deleteTitle', { ns: 'common' })}</div>
<p className="mb-8 text-text-secondary">{t('avatar.deleteDescription', { ns: 'common' })}</p>
<div className="flex w-full items-center justify-center gap-2">

View File

@@ -209,14 +209,14 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
</div>
{step === STEP.start && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.title', { ns: 'common' })}</div>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.title', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-warning body-md-medium">{t('account.changeEmail.authTip', { ns: 'common' })}</div>
<div className="text-text-secondary body-md-regular">
<div className="body-md-medium text-text-warning">{t('account.changeEmail.authTip', { ns: 'common' })}</div>
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey="account.changeEmail.content1"
ns="common"
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email }}
/>
</div>
@@ -241,19 +241,19 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
)}
{step === STEP.verifyOrigin && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.verifyEmail', { ns: 'common' })}</div>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.verifyEmail', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-secondary body-md-regular">
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey="account.changeEmail.content2"
ns="common"
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email }}
/>
</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
<Input
className="!w-full"
placeholder={t('account.changeEmail.codePlaceholder', { ns: 'common' })}
@@ -278,25 +278,25 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
<div className="mt-3 flex items-center gap-1 text-text-tertiary system-xs-regular">
<div className="system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary">
<span>{t('account.changeEmail.resendTip', { ns: 'common' })}</span>
{time > 0 && (
<span>{t('account.changeEmail.resendCount', { ns: 'common', count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToOriginEmail} className="cursor-pointer text-text-accent-secondary system-xs-medium">{t('account.changeEmail.resend', { ns: 'common' })}</span>
<span onClick={sendCodeToOriginEmail} className="system-xs-medium cursor-pointer text-text-accent-secondary">{t('account.changeEmail.resend', { ns: 'common' })}</span>
)}
</div>
</>
)}
{step === STEP.newEmail && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.newEmail', { ns: 'common' })}</div>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.newEmail', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-secondary body-md-regular">{t('account.changeEmail.content3', { ns: 'common' })}</div>
<div className="body-md-regular text-text-secondary">{t('account.changeEmail.content3', { ns: 'common' })}</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.emailLabel', { ns: 'common' })}</div>
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">{t('account.changeEmail.emailLabel', { ns: 'common' })}</div>
<Input
className="!w-full"
placeholder={t('account.changeEmail.emailPlaceholder', { ns: 'common' })}
@@ -305,10 +305,10 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
destructive={newEmailExited || unAvailableEmail}
/>
{newEmailExited && (
<div className="mt-1 py-0.5 text-text-destructive body-xs-regular">{t('account.changeEmail.existingEmail', { ns: 'common' })}</div>
<div className="body-xs-regular mt-1 py-0.5 text-text-destructive">{t('account.changeEmail.existingEmail', { ns: 'common' })}</div>
)}
{unAvailableEmail && (
<div className="mt-1 py-0.5 text-text-destructive body-xs-regular">{t('account.changeEmail.unAvailableEmail', { ns: 'common' })}</div>
<div className="body-xs-regular mt-1 py-0.5 text-text-destructive">{t('account.changeEmail.unAvailableEmail', { ns: 'common' })}</div>
)}
</div>
<div className="mt-3 space-y-2">
@@ -331,19 +331,19 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
)}
{step === STEP.verifyNew && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.verifyNew', { ns: 'common' })}</div>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.verifyNew', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-secondary body-md-regular">
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey="account.changeEmail.content4"
ns="common"
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email: mail }}
/>
</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
<Input
className="!w-full"
placeholder={t('account.changeEmail.codePlaceholder', { ns: 'common' })}
@@ -368,13 +368,13 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
<div className="mt-3 flex items-center gap-1 text-text-tertiary system-xs-regular">
<div className="system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary">
<span>{t('account.changeEmail.resendTip', { ns: 'common' })}</span>
{time > 0 && (
<span>{t('account.changeEmail.resendCount', { ns: 'common', count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToNewEmail} className="cursor-pointer text-text-accent-secondary system-xs-medium">{t('account.changeEmail.resend', { ns: 'common' })}</span>
<span onClick={sendCodeToNewEmail} className="system-xs-medium cursor-pointer text-text-accent-secondary">{t('account.changeEmail.resend', { ns: 'common' })}</span>
)}
</div>
</>

View File

@@ -138,7 +138,7 @@ export default function AccountPage() {
imageUrl={icon_url}
/>
</div>
<div className="mt-[3px] text-text-secondary system-sm-medium">{item.name}</div>
<div className="system-sm-medium mt-[3px] text-text-secondary">{item.name}</div>
</div>
)
}
@@ -146,12 +146,12 @@ export default function AccountPage() {
return (
<>
<div className="pb-3 pt-2">
<h4 className="text-text-primary title-2xl-semi-bold">{t('account.myAccount', { ns: 'common' })}</h4>
<h4 className="title-2xl-semi-bold text-text-primary">{t('account.myAccount', { ns: 'common' })}</h4>
</div>
<div className="mb-8 flex items-center rounded-xl bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6">
<AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={mutateUserProfile} size={64} />
<div className="ml-4">
<p className="text-text-primary system-xl-semibold">
<p className="system-xl-semibold text-text-primary">
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size="s" color="blue" className="ml-1 !px-2">
@@ -160,16 +160,16 @@ export default function AccountPage() {
</PremiumBadge>
)}
</p>
<p className="text-text-tertiary system-xs-regular">{userProfile.email}</p>
<p className="system-xs-regular text-text-tertiary">{userProfile.email}</p>
</div>
</div>
<div className="mb-8">
<div className={titleClassName}>{t('account.name', { ns: 'common' })}</div>
<div className="mt-2 flex w-full items-center justify-between gap-2">
<div className="flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled system-sm-regular">
<div className="system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled ">
<span className="pl-1">{userProfile.name}</span>
</div>
<div className="cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text system-sm-medium" onClick={handleEditName}>
<div className="system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text" onClick={handleEditName}>
{t('operation.edit', { ns: 'common' })}
</div>
</div>
@@ -177,11 +177,11 @@ export default function AccountPage() {
<div className="mb-8">
<div className={titleClassName}>{t('account.email', { ns: 'common' })}</div>
<div className="mt-2 flex w-full items-center justify-between gap-2">
<div className="flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled system-sm-regular">
<div className="system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled ">
<span className="pl-1">{userProfile.email}</span>
</div>
{systemFeatures.enable_change_email && (
<div className="cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text system-sm-medium" onClick={() => setShowUpdateEmail(true)}>
<div className="system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text" onClick={() => setShowUpdateEmail(true)}>
{t('operation.change', { ns: 'common' })}
</div>
)}
@@ -191,8 +191,8 @@ export default function AccountPage() {
systemFeatures.enable_email_password_login && (
<div className="mb-8 flex justify-between gap-2">
<div>
<div className="mb-1 text-text-secondary system-sm-semibold">{t('account.password', { ns: 'common' })}</div>
<div className="mb-2 text-text-tertiary body-xs-regular">{t('account.passwordTip', { ns: 'common' })}</div>
<div className="system-sm-semibold mb-1 text-text-secondary">{t('account.password', { ns: 'common' })}</div>
<div className="body-xs-regular mb-2 text-text-tertiary">{t('account.passwordTip', { ns: 'common' })}</div>
</div>
<Button onClick={() => setEditPasswordModalVisible(true)}>{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}</Button>
</div>
@@ -219,7 +219,7 @@ export default function AccountPage() {
onClose={() => setEditNameModalVisible(false)}
className="!w-[420px] !p-6"
>
<div className="mb-6 text-text-primary title-2xl-semi-bold">{t('account.editName', { ns: 'common' })}</div>
<div className="title-2xl-semi-bold mb-6 text-text-primary">{t('account.editName', { ns: 'common' })}</div>
<div className={titleClassName}>{t('account.name', { ns: 'common' })}</div>
<Input
className="mt-2"
@@ -249,7 +249,7 @@ export default function AccountPage() {
}}
className="!w-[420px] !p-6"
>
<div className="mb-6 text-text-primary title-2xl-semi-bold">{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}</div>
<div className="title-2xl-semi-bold mb-6 text-text-primary">{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}</div>
{userProfile.is_password_set && (
<>
<div className={titleClassName}>{t('account.currentPassword', { ns: 'common' })}</div>
@@ -272,7 +272,7 @@ export default function AccountPage() {
</div>
</>
)}
<div className="mt-8 text-text-secondary system-sm-semibold">
<div className="system-sm-semibold mt-8 text-text-secondary">
{userProfile.is_password_set ? t('account.newPassword', { ns: 'common' }) : t('account.password', { ns: 'common' })}
</div>
<div className="relative mt-2">
@@ -291,7 +291,7 @@ export default function AccountPage() {
</Button>
</div>
</div>
<div className="mt-8 text-text-secondary system-sm-semibold">{t('account.confirmPassword', { ns: 'common' })}</div>
<div className="system-sm-semibold mt-8 text-text-secondary">{t('account.confirmPassword', { ns: 'common' })}</div>
<div className="relative mt-2">
<Input
type={showConfirmPassword ? 'text' : 'password'}

View File

@@ -94,7 +94,7 @@ const CSVUploader: FC<Props> = ({
/>
<div ref={dropRef}>
{!file && (
<div className={cn('flex h-20 items-center rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg system-sm-regular', dragging && 'border border-components-dropzone-border-accent bg-components-dropzone-bg-accent')}>
<div className={cn('system-sm-regular flex h-20 items-center rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg', dragging && 'border border-components-dropzone-border-accent bg-components-dropzone-bg-accent')}>
<div className="flex w-full items-center justify-center space-x-2">
<CSVIcon className="shrink-0" />
<div className="text-text-tertiary">

View File

@@ -178,7 +178,7 @@ const Prompt: FC<ISimplePromptInput> = ({
{!noTitle && (
<div className="flex h-11 items-center justify-between pl-3 pr-2.5">
<div className="flex items-center space-x-1">
<div className="h2 text-text-secondary system-sm-semibold-uppercase">{mode !== AppModeEnum.COMPLETION ? t('chatSubTitle', { ns: 'appDebug' }) : t('completionSubTitle', { ns: 'appDebug' })}</div>
<div className="h2 system-sm-semibold-uppercase text-text-secondary">{mode !== AppModeEnum.COMPLETION ? t('chatSubTitle', { ns: 'appDebug' }) : t('completionSubTitle', { ns: 'appDebug' })}</div>
{!readonly && (
<Tooltip
popupContent={(

View File

@@ -96,7 +96,7 @@ const Editor: FC<Props> = ({
)}
</div>
</div>
<div className={cn(editorHeight, 'min-h-[102px] overflow-y-auto px-4 text-sm text-gray-700')}>
<div className={cn(editorHeight, ' min-h-[102px] overflow-y-auto px-4 text-sm text-gray-700')}>
<PromptEditor
className={editorHeight}
value={value}

View File

@@ -210,7 +210,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
<div className="overflow-y-auto border-b border-divider-regular p-6 pb-[68px] pt-5">
<div className={cn(rowClass, 'items-center')}>
<div className={labelClass}>
<div className="text-text-secondary system-sm-semibold">{t('form.name', { ns: 'datasetSettings' })}</div>
<div className="system-sm-semibold text-text-secondary">{t('form.name', { ns: 'datasetSettings' })}</div>
</div>
<Input
value={localeCurrentDataset.name}
@@ -221,7 +221,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
</div>
<div className={cn(rowClass)}>
<div className={labelClass}>
<div className="text-text-secondary system-sm-semibold">{t('form.desc', { ns: 'datasetSettings' })}</div>
<div className="system-sm-semibold text-text-secondary">{t('form.desc', { ns: 'datasetSettings' })}</div>
</div>
<div className="w-full">
<Textarea
@@ -234,7 +234,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
</div>
<div className={rowClass}>
<div className={labelClass}>
<div className="text-text-secondary system-sm-semibold">{t('form.permissions', { ns: 'datasetSettings' })}</div>
<div className="system-sm-semibold text-text-secondary">{t('form.permissions', { ns: 'datasetSettings' })}</div>
</div>
<div className="w-full">
<PermissionSelector
@@ -250,7 +250,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
{!!(currentDataset && currentDataset.indexing_technique) && (
<div className={cn(rowClass)}>
<div className={labelClass}>
<div className="text-text-secondary system-sm-semibold">{t('form.indexMethod', { ns: 'datasetSettings' })}</div>
<div className="system-sm-semibold text-text-secondary">{t('form.indexMethod', { ns: 'datasetSettings' })}</div>
</div>
<div className="grow">
<IndexMethod
@@ -267,7 +267,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
{indexMethod === IndexingType.QUALIFIED && (
<div className={cn(rowClass)}>
<div className={labelClass}>
<div className="text-text-secondary system-sm-semibold">{t('form.embeddingModel', { ns: 'datasetSettings' })}</div>
<div className="system-sm-semibold text-text-secondary">{t('form.embeddingModel', { ns: 'datasetSettings' })}</div>
</div>
<div className="w-full">
<div className="h-8 w-full rounded-lg bg-components-input-bg-normal opacity-60">

View File

@@ -394,7 +394,7 @@ const Debug: FC<IDebug> = ({
<>
<div className="shrink-0">
<div className="flex items-center justify-between px-4 pb-2 pt-3">
<div className="text-text-primary system-xl-semibold">{t('inputs.title', { ns: 'appDebug' })}</div>
<div className="system-xl-semibold text-text-primary">{t('inputs.title', { ns: 'appDebug' })}</div>
<div className="flex items-center">
{
debugWithMultipleModel
@@ -539,7 +539,7 @@ const Debug: FC<IDebug> = ({
{!completionRes && !isResponding && (
<div className="flex grow flex-col items-center justify-center gap-2">
<RiSparklingFill className="h-12 w-12 text-text-empty-state-icon" />
<div className="text-text-quaternary system-sm-regular">{t('noResult', { ns: 'appDebug' })}</div>
<div className="system-sm-regular text-text-quaternary">{t('noResult', { ns: 'appDebug' })}</div>
</div>
)}
</>

View File

@@ -966,10 +966,10 @@ const Configuration: FC = () => {
<div className="bg-default-subtle absolute left-0 top-0 h-14 w-full">
<div className="flex h-14 items-center justify-between px-6">
<div className="flex items-center">
<div className="text-text-primary system-xl-semibold">{t('orchestrate', { ns: 'appDebug' })}</div>
<div className="system-xl-semibold text-text-primary">{t('orchestrate', { ns: 'appDebug' })}</div>
<div className="flex h-[14px] items-center space-x-1 text-xs">
{isAdvancedMode && (
<div className="ml-1 flex h-5 items-center rounded-md border border-components-button-secondary-border px-1.5 uppercase text-text-tertiary system-xs-medium-uppercase">{t('promptMode.advanced', { ns: 'appDebug' })}</div>
<div className="system-xs-medium-uppercase ml-1 flex h-5 items-center rounded-md border border-components-button-secondary-border px-1.5 uppercase text-text-tertiary">{t('promptMode.advanced', { ns: 'appDebug' })}</div>
)}
</div>
</div>
@@ -1030,8 +1030,8 @@ const Configuration: FC = () => {
<Config />
</div>
{!isMobile && (
<div className="relative flex h-full w-1/2 grow flex-col overflow-y-auto" style={{ borderColor: 'rgba(0, 0, 0, 0.02)' }}>
<div className="flex grow flex-col rounded-tl-2xl border-l-[0.5px] border-t-[0.5px] border-components-panel-border bg-chatbot-bg">
<div className="relative flex h-full w-1/2 grow flex-col overflow-y-auto " style={{ borderColor: 'rgba(0, 0, 0, 0.02)' }}>
<div className="flex grow flex-col rounded-tl-2xl border-l-[0.5px] border-t-[0.5px] border-components-panel-border bg-chatbot-bg ">
<Debug
isAPIKeySet={isAPIKeySet}
onSetting={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })}

View File

@@ -217,7 +217,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
<AppIcon
size="large"
onClick={() => { setShowEmojiPicker(true) }}
className="!h-9 !w-9 cursor-pointer rounded-lg border-[0.5px] border-components-panel-border"
className="!h-9 !w-9 cursor-pointer rounded-lg border-[0.5px] border-components-panel-border "
icon={localeData.icon}
background={localeData.icon_background}
/>

View File

@@ -117,10 +117,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
<div className="px-10">
<div className="h-6 w-full 2xl:h-[139px]" />
<div className="pb-6 pt-1">
<span className="text-text-primary title-2xl-semi-bold">{t('newApp.startFromBlank', { ns: 'app' })}</span>
<span className="title-2xl-semi-bold text-text-primary">{t('newApp.startFromBlank', { ns: 'app' })}</span>
</div>
<div className="mb-2 leading-6">
<span className="text-text-secondary system-sm-semibold">{t('newApp.chooseAppType', { ns: 'app' })}</span>
<span className="system-sm-semibold text-text-secondary">{t('newApp.chooseAppType', { ns: 'app' })}</span>
</div>
<div className="flex w-[660px] flex-col gap-4">
<div>
@@ -160,7 +160,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
className="flex cursor-pointer items-center border-0 bg-transparent p-0"
onClick={() => setIsAppTypeExpanded(!isAppTypeExpanded)}
>
<span className="text-text-tertiary system-2xs-medium-uppercase">{t('newApp.forBeginners', { ns: 'app' })}</span>
<span className="system-2xs-medium-uppercase text-text-tertiary">{t('newApp.forBeginners', { ns: 'app' })}</span>
<RiArrowRightSLine className={`ml-1 h-4 w-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`} />
</button>
</div>
@@ -212,7 +212,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
<div className="flex items-center space-x-3">
<div className="flex-1">
<div className="mb-1 flex h-6 items-center">
<label className="text-text-secondary system-sm-semibold">{t('newApp.captionName', { ns: 'app' })}</label>
<label className="system-sm-semibold text-text-secondary">{t('newApp.captionName', { ns: 'app' })}</label>
</div>
<Input
value={name}
@@ -243,8 +243,8 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</div>
<div>
<div className="mb-1 flex h-6 items-center">
<label className="text-text-secondary system-sm-semibold">{t('newApp.captionDescription', { ns: 'app' })}</label>
<span className="ml-1 text-text-tertiary system-xs-regular">
<label className="system-sm-semibold text-text-secondary">{t('newApp.captionDescription', { ns: 'app' })}</label>
<span className="system-xs-regular ml-1 text-text-tertiary">
(
{t('newApp.optional', { ns: 'app' })}
)
@@ -260,7 +260,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</div>
{isAppsFull && <AppsFull className="mt-4" loc="app-create" />}
<div className="flex items-center justify-between pb-10 pt-5">
<div className="flex cursor-pointer items-center gap-1 text-text-tertiary system-xs-regular" onClick={onCreateFromTemplate}>
<div className="system-xs-regular flex cursor-pointer items-center gap-1 text-text-tertiary" onClick={onCreateFromTemplate}>
<span>{t('newApp.noIdeaTip', { ns: 'app' })}</span>
<div className="p-[1px]">
<RiArrowRightLine className="h-3.5 w-3.5" />
@@ -334,8 +334,8 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
onClick={onClick}
>
{icon}
<div className="mb-0.5 mt-2 text-text-secondary system-sm-semibold">{title}</div>
<div className="line-clamp-2 text-text-tertiary system-xs-regular" title={description}>{description}</div>
<div className="system-sm-semibold mb-0.5 mt-2 text-text-secondary">{title}</div>
<div className="system-xs-regular line-clamp-2 text-text-tertiary" title={description}>{description}</div>
</div>
)
}
@@ -367,8 +367,8 @@ function AppPreview({ mode }: { mode: AppModeEnum }) {
const previewInfo = modeToPreviewInfoMap[mode]
return (
<div className="px-8 py-4">
<h4 className="text-text-secondary system-sm-semibold-uppercase">{previewInfo.title}</h4>
<div className="mt-1 min-h-8 max-w-96 text-text-tertiary system-xs-regular">
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4>
<div className="system-xs-regular mt-1 min-h-8 max-w-96 text-text-tertiary">
<span>{previewInfo.description}</span>
</div>
</div>

View File

@@ -232,7 +232,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
isShow={show}
onClose={noop}
>
<div className="flex items-center justify-between pb-3 pl-6 pr-5 pt-6 text-text-primary title-2xl-semi-bold">
<div className="title-2xl-semi-bold flex items-center justify-between pb-3 pl-6 pr-5 pt-6 text-text-primary">
{t('importFromDSL', { ns: 'app' })}
<div
className="flex h-8 w-8 cursor-pointer items-center"
@@ -241,7 +241,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
</div>
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 text-text-tertiary system-md-semibold">
<div className="system-md-semibold flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 text-text-tertiary">
{
tabs.map(tab => (
<div
@@ -275,7 +275,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
{
currentTab === CreateFromDSLModalTab.FROM_URL && (
<div>
<div className="mb-1 text-text-secondary system-md-semibold">DSL URL</div>
<div className="system-md-semibold mb-1 text-text-secondary">DSL URL</div>
<Input
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
value={dslUrlValue}
@@ -309,8 +309,8 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
className="w-[480px]"
>
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<div className="text-text-primary title-2xl-semi-bold">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
<div className="flex grow flex-col text-text-secondary system-md-regular">
<div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
<div className="system-md-regular flex grow flex-col text-text-secondary">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />

View File

@@ -121,7 +121,7 @@ const Uploader: FC<Props> = ({
</div>
)}
{file && (
<div className={cn('group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs', 'hover:bg-components-panel-on-panel-item-bg-hover')}>
<div className={cn('group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs', ' hover:bg-components-panel-on-panel-item-bg-hover')}>
<div className="flex items-center justify-center p-3">
<YamlIcon className="h-6 w-6 shrink-0" />
</div>

View File

@@ -96,7 +96,7 @@ const statusTdRender = (statusCount: StatusCount) => {
if (statusCount.paused > 0) {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<Indicator color="yellow" />
<span className="text-util-colors-warning-warning-600">Pending</span>
</div>
@@ -104,7 +104,7 @@ const statusTdRender = (statusCount: StatusCount) => {
}
else if (statusCount.partial_success + statusCount.failed === 0) {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<Indicator color="green" />
<span className="text-util-colors-green-green-600">Success</span>
</div>
@@ -112,7 +112,7 @@ const statusTdRender = (statusCount: StatusCount) => {
}
else if (statusCount.failed === 0) {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<Indicator color="green" />
<span className="text-util-colors-green-green-600">Partial Success</span>
</div>
@@ -120,7 +120,7 @@ const statusTdRender = (statusCount: StatusCount) => {
}
else {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<Indicator color="red" />
<span className="text-util-colors-red-red-600">
{statusCount.failed}
@@ -562,9 +562,9 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
{/* Panel Header */}
<div className="flex shrink-0 items-center gap-2 rounded-t-xl bg-components-panel-bg pb-2 pl-4 pr-3 pt-3">
<div className="shrink-0">
<div className="mb-0.5 text-text-primary system-xs-semibold-uppercase">{isChatMode ? t('detail.conversationId', { ns: 'appLog' }) : t('detail.time', { ns: 'appLog' })}</div>
<div className="system-xs-semibold-uppercase mb-0.5 text-text-primary">{isChatMode ? t('detail.conversationId', { ns: 'appLog' }) : t('detail.time', { ns: 'appLog' })}</div>
{isChatMode && (
<div className="flex items-center text-text-secondary system-2xs-regular-uppercase">
<div className="system-2xs-regular-uppercase flex items-center text-text-secondary">
<Tooltip
popupContent={detail.id}
>
@@ -574,7 +574,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
</div>
)}
{!isChatMode && (
<div className="text-text-secondary system-2xs-regular-uppercase">{formatTime(detail.created_at, t('dateTimeFormat', { ns: 'appLog' }) as string)}</div>
<div className="system-2xs-regular-uppercase text-text-secondary">{formatTime(detail.created_at, t('dateTimeFormat', { ns: 'appLog' }) as string)}</div>
)}
</div>
<div className="flex grow flex-wrap items-center justify-end gap-y-1">
@@ -600,7 +600,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
? (
<div className="px-6 py-4">
<div className="flex h-[18px] items-center space-x-3">
<div className="text-text-tertiary system-xs-semibold-uppercase">{t('table.header.output', { ns: 'appLog' })}</div>
<div className="system-xs-semibold-uppercase text-text-tertiary">{t('table.header.output', { ns: 'appLog' })}</div>
<div
className="h-px grow"
style={{
@@ -692,7 +692,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
</div>
{hasMore && (
<div className="py-3 text-center">
<div className="text-text-tertiary system-xs-regular">
<div className="system-xs-regular text-text-tertiary">
{t('detail.loading', { ns: 'appLog' })}
...
</div>
@@ -950,7 +950,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
)}
popupClassName={(isHighlight && !isChatMode) ? '' : '!hidden'}
>
<div className={cn(isEmptyStyle ? 'text-text-quaternary' : 'text-text-secondary', !isHighlight ? '' : 'bg-orange-100', 'overflow-hidden text-ellipsis whitespace-nowrap system-sm-regular')}>
<div className={cn(isEmptyStyle ? 'text-text-quaternary' : 'text-text-secondary', !isHighlight ? '' : 'bg-orange-100', 'system-sm-regular overflow-hidden text-ellipsis whitespace-nowrap')}>
{value || '-'}
</div>
</Tooltip>
@@ -963,7 +963,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
return (
<div className="relative mt-2 grow overflow-x-auto">
<table className={cn('w-full min-w-[440px] border-collapse border-0')}>
<thead className="text-text-tertiary system-xs-medium-uppercase">
<thead className="system-xs-medium-uppercase text-text-tertiary">
<tr>
<td className="w-5 whitespace-nowrap rounded-l-lg bg-background-section-burn pl-2 pr-1"></td>
<td className="whitespace-nowrap bg-background-section-burn py-1.5 pl-3">{isChatMode ? t('table.header.summary', { ns: 'appLog' }) : t('table.header.input', { ns: 'appLog' })}</td>
@@ -976,7 +976,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
<td className="whitespace-nowrap rounded-r-lg bg-background-section-burn py-1.5 pl-3">{t('table.header.time', { ns: 'appLog' })}</td>
</tr>
</thead>
<tbody className="text-text-secondary system-sm-regular">
<tbody className="system-sm-regular text-text-secondary">
{logs.data.map((log: any) => {
const endUser = log.from_end_user_session_id || log.from_account_name
const leftValue = get(log, isChatMode ? 'name' : 'message.inputs.query') || (!isChatMode ? (get(log, 'message.query') || get(log, 'message.inputs.default_input')) : '') || ''

View File

@@ -231,12 +231,12 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{/* header */}
<div className="pb-3 pl-6 pr-5 pt-5">
<div className="flex items-center gap-1">
<div className="grow text-text-primary title-2xl-semi-bold">{t(`${prefixSettings}.title`, { ns: 'appOverview' })}</div>
<div className="title-2xl-semi-bold grow text-text-primary">{t(`${prefixSettings}.title`, { ns: 'appOverview' })}</div>
<ActionButton className="shrink-0" onClick={onHide}>
<RiCloseLine className="h-4 w-4" />
</ActionButton>
</div>
<div className="mt-0.5 text-text-tertiary system-xs-regular">
<div className="system-xs-regular mt-0.5 text-text-tertiary">
<span>{t(`${prefixSettings}.modalTip`, { ns: 'appOverview' })}</span>
</div>
</div>
@@ -245,7 +245,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{/* name & icon */}
<div className="flex gap-4">
<div className="grow">
<div className={cn('mb-1 py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.webName`, { ns: 'appOverview' })}</div>
<div className={cn('system-sm-semibold mb-1 py-1 text-text-secondary')}>{t(`${prefixSettings}.webName`, { ns: 'appOverview' })}</div>
<Input
className="w-full"
value={inputInfo.title}
@@ -265,32 +265,32 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</div>
{/* description */}
<div className="relative">
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}</div>
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}</div>
<Textarea
className="mt-1"
value={inputInfo.desc}
onChange={e => onDesChange(e.target.value)}
placeholder={t(`${prefixSettings}.webDescPlaceholder`, { ns: 'appOverview' }) as string}
/>
<p className={cn('pb-0.5 text-text-tertiary body-xs-regular')}>{t(`${prefixSettings}.webDescTip`, { ns: 'appOverview' })}</p>
<p className={cn('body-xs-regular pb-0.5 text-text-tertiary')}>{t(`${prefixSettings}.webDescTip`, { ns: 'appOverview' })}</p>
</div>
<Divider className="my-0 h-px" />
{/* answer icon */}
{isChat && (
<div className="w-full">
<div className="flex items-center justify-between">
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t('answerIcon.title', { ns: 'app' })}</div>
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t('answerIcon.title', { ns: 'app' })}</div>
<Switch
value={inputInfo.use_icon_as_answer_icon}
onChange={v => setInputInfo({ ...inputInfo, use_icon_as_answer_icon: v })}
/>
</div>
<p className="pb-0.5 text-text-tertiary body-xs-regular">{t('answerIcon.description', { ns: 'app' })}</p>
<p className="body-xs-regular pb-0.5 text-text-tertiary">{t('answerIcon.description', { ns: 'app' })}</p>
</div>
)}
{/* language */}
<div className="flex items-center">
<div className={cn('grow py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div>
<div className={cn('system-sm-semibold grow py-1 text-text-secondary')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div>
<SimpleSelect
wrapperClassName="w-[200px]"
items={languages.filter(item => item.supported)}
@@ -303,8 +303,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{isChat && (
<div className="flex items-center">
<div className="grow">
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.chatColorTheme`, { ns: 'appOverview' })}</div>
<div className="pb-0.5 text-text-tertiary body-xs-regular">{t(`${prefixSettings}.chatColorThemeDesc`, { ns: 'appOverview' })}</div>
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.chatColorTheme`, { ns: 'appOverview' })}</div>
<div className="body-xs-regular pb-0.5 text-text-tertiary">{t(`${prefixSettings}.chatColorThemeDesc`, { ns: 'appOverview' })}</div>
</div>
<div className="shrink-0">
<Input
@@ -314,7 +314,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
placeholder="E.g #A020F0"
/>
<div className="flex items-center justify-between">
<p className={cn('text-text-tertiary body-xs-regular')}>{t(`${prefixSettings}.chatColorThemeInverted`, { ns: 'appOverview' })}</p>
<p className={cn('body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.chatColorThemeInverted`, { ns: 'appOverview' })}</p>
<Switch value={inputInfo.chatColorThemeInverted} onChange={v => setInputInfo({ ...inputInfo, chatColorThemeInverted: v })}></Switch>
</div>
</div>
@@ -323,22 +323,22 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{/* workflow detail */}
<div className="w-full">
<div className="flex items-center justify-between">
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.workflow.subTitle`, { ns: 'appOverview' })}</div>
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.workflow.subTitle`, { ns: 'appOverview' })}</div>
<Switch
disabled={!(appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT)}
value={inputInfo.show_workflow_steps}
onChange={v => setInputInfo({ ...inputInfo, show_workflow_steps: v })}
/>
</div>
<p className="pb-0.5 text-text-tertiary body-xs-regular">{t(`${prefixSettings}.workflow.showDesc`, { ns: 'appOverview' })}</p>
<p className="body-xs-regular pb-0.5 text-text-tertiary">{t(`${prefixSettings}.workflow.showDesc`, { ns: 'appOverview' })}</p>
</div>
{/* more settings switch */}
<Divider className="my-0 h-px" />
{!isShowMore && (
<div className="flex cursor-pointer items-center" onClick={() => setIsShowMore(true)}>
<div className="grow">
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.more.entry`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 text-text-tertiary body-xs-regular')}>
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.more.entry`, { ns: 'appOverview' })}</div>
<p className={cn('body-xs-regular pb-0.5 text-text-tertiary')}>
{t(`${prefixSettings}.more.copyRightPlaceholder`, { ns: 'appOverview' })}
{' '}
&
@@ -356,7 +356,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<div className="w-full">
<div className="flex items-center">
<div className="flex grow items-center">
<div className={cn('mr-1 py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.more.copyright`, { ns: 'appOverview' })}</div>
<div className={cn('system-sm-semibold mr-1 py-1 text-text-secondary')}>{t(`${prefixSettings}.more.copyright`, { ns: 'appOverview' })}</div>
{/* upgrade button */}
{enableBilling && isFreePlan && (
<div className="h-[18px] select-none">
@@ -385,7 +385,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
/>
</Tooltip>
</div>
<p className="pb-0.5 text-text-tertiary body-xs-regular">{t(`${prefixSettings}.more.copyrightTip`, { ns: 'appOverview' })}</p>
<p className="body-xs-regular pb-0.5 text-text-tertiary">{t(`${prefixSettings}.more.copyrightTip`, { ns: 'appOverview' })}</p>
{inputInfo.copyrightSwitchValue && (
<Input
className="mt-2 h-10"
@@ -397,8 +397,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</div>
{/* privacy policy */}
<div className="w-full">
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.more.privacyPolicy`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 text-text-tertiary body-xs-regular')}>
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.more.privacyPolicy`, { ns: 'appOverview' })}</div>
<p className={cn('body-xs-regular pb-0.5 text-text-tertiary')}>
<Trans
i18nKey={`${prefixSettings}.more.privacyPolicyTip`}
ns="appOverview"
@@ -414,8 +414,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</div>
{/* custom disclaimer */}
<div className="w-full">
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 text-text-tertiary body-xs-regular')}>{t(`${prefixSettings}.more.customDisclaimerTip`, { ns: 'appOverview' })}</p>
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}</div>
<p className={cn('body-xs-regular pb-0.5 text-text-tertiary')}>{t(`${prefixSettings}.more.customDisclaimerTip`, { ns: 'appOverview' })}</p>
<Textarea
className="mt-1"
value={inputInfo.customDisclaimer}

View File

@@ -200,14 +200,14 @@ const ChatInputArea = ({
<div className="relative flex w-full grow items-center">
<div
ref={textValueRef}
className="pointer-events-none invisible absolute h-auto w-auto whitespace-pre p-1 leading-6 body-lg-regular"
className="body-lg-regular pointer-events-none invisible absolute h-auto w-auto whitespace-pre p-1 leading-6"
>
{query}
</div>
<Textarea
ref={ref => textareaRef.current = ref as any}
className={cn(
'w-full resize-none bg-transparent p-1 leading-6 text-text-primary outline-none body-lg-regular',
'body-lg-regular w-full resize-none bg-transparent p-1 leading-6 text-text-primary outline-none',
)}
placeholder={decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')}
autoFocus

View File

@@ -1,4 +0,0 @@
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 5C2 3.44487 2.58482 1.98537 3.54004 1.04932C2.17681 1.34034 1 2.90001 1 5C1 7.09996 2.17685 8.65912 3.54004 8.9502C2.58496 8.01413 2 6.55501 2 5ZM3 5C3 7.33338 4.4528 9 6 9C7.5472 9 9 7.33338 9 5C9 2.66664 7.5472 1 6 1C4.4528 1 3 2.66664 3 5ZM10 5C10 7.63722 8.3188 10 6 10H4C1.6812 10 0 7.63722 0 5C0 2.3628 1.6812 0 4 0H6C8.3188 0 10 2.3628 10 5Z" fill="#676F83"/>
<path d="M6.71519 4.09259L6.45385 3.18667C6.42141 3.07421 6.34037 3 6.25 3C6.15963 3 6.07859 3.07421 6.04615 3.18667L5.78481 4.09259C5.74675 4.22464 5.66849 4.32899 5.56945 4.37978L4.88999 4.7282C4.80565 4.77146 4.75 4.87951 4.75 5C4.75 5.12049 4.80565 5.22854 4.88999 5.2718L5.56945 5.62022C5.66849 5.67101 5.74675 5.77536 5.78481 5.90741L6.04615 6.81333C6.07859 6.92579 6.15963 7 6.25 7C6.34037 7 6.42141 6.92579 6.45385 6.81333L6.71519 5.90741C6.75325 5.77536 6.83151 5.67101 6.93055 5.62022L7.61001 5.2718C7.69435 5.22854 7.75 5.12049 7.75 5C7.75 4.87951 7.69435 4.77146 7.61001 4.7282L6.93055 4.37978C6.83151 4.32899 6.75325 4.22464 6.71519 4.09259Z" fill="#676F83"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="arrow-down-round-fill">
<path id="Vector" d="M6.02913 6.23572C5.08582 6.23572 4.56482 7.33027 5.15967 8.06239L7.13093 10.4885C7.57922 11.0403 8.42149 11.0403 8.86986 10.4885L10.8411 8.06239C11.4359 7.33027 10.9149 6.23572 9.97158 6.23572H6.02913Z" fill="currentColor"/>
<path id="Vector" d="M6.02913 6.23572C5.08582 6.23572 4.56482 7.33027 5.15967 8.06239L7.13093 10.4885C7.57922 11.0403 8.42149 11.0403 8.86986 10.4885L10.8411 8.06239C11.4359 7.33027 10.9149 6.23572 9.97158 6.23572H6.02913Z" fill="#101828"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 380 B

View File

@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Solid" fill-rule="evenodd" clip-rule="evenodd" d="M8.00008 0.666016C3.94999 0.666016 0.666748 3.94926 0.666748 7.99935C0.666748 12.0494 3.94999 15.3327 8.00008 15.3327C12.0502 15.3327 15.3334 12.0494 15.3334 7.99935C15.3334 3.94926 12.0502 0.666016 8.00008 0.666016ZM10.4715 5.52794C10.7318 5.78829 10.7318 6.2104 10.4715 6.47075L8.94289 7.99935L10.4715 9.52794C10.7318 9.78829 10.7318 10.2104 10.4715 10.4708C10.2111 10.7311 9.78903 10.7311 9.52868 10.4708L8.00008 8.94216L6.47149 10.4708C6.21114 10.7311 5.78903 10.7311 5.52868 10.4708C5.26833 10.2104 5.26833 9.78829 5.52868 9.52794L7.05727 7.99935L5.52868 6.47075C5.26833 6.2104 5.26833 5.78829 5.52868 5.52794C5.78903 5.26759 6.21114 5.26759 6.47149 5.52794L8.00008 7.05654L9.52868 5.52794C9.78903 5.26759 10.2111 5.26759 10.4715 5.52794Z" fill="currentColor"/>
<path id="Solid" fill-rule="evenodd" clip-rule="evenodd" d="M8.00008 0.666016C3.94999 0.666016 0.666748 3.94926 0.666748 7.99935C0.666748 12.0494 3.94999 15.3327 8.00008 15.3327C12.0502 15.3327 15.3334 12.0494 15.3334 7.99935C15.3334 3.94926 12.0502 0.666016 8.00008 0.666016ZM10.4715 5.52794C10.7318 5.78829 10.7318 6.2104 10.4715 6.47075L8.94289 7.99935L10.4715 9.52794C10.7318 9.78829 10.7318 10.2104 10.4715 10.4708C10.2111 10.7311 9.78903 10.7311 9.52868 10.4708L8.00008 8.94216L6.47149 10.4708C6.21114 10.7311 5.78903 10.7311 5.52868 10.4708C5.26833 10.2104 5.26833 9.78829 5.52868 9.52794L7.05727 7.99935L5.52868 6.47075C5.26833 6.2104 5.26833 5.78829 5.52868 5.52794C5.78903 5.26759 6.21114 5.26759 6.47149 5.52794L8.00008 7.05654L9.52868 5.52794C9.78903 5.26759 10.2111 5.26759 10.4715 5.52794Z" fill="#98A2B3"/>
</svg>

Before

Width:  |  Height:  |  Size: 930 B

After

Width:  |  Height:  |  Size: 925 B

View File

@@ -1,35 +0,0 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "10",
"height": "10",
"viewBox": "0 0 10 10",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M2 5C2 3.44487 2.58482 1.98537 3.54004 1.04932C2.17681 1.34034 1 2.90001 1 5C1 7.09996 2.17685 8.65912 3.54004 8.9502C2.58496 8.01413 2 6.55501 2 5ZM3 5C3 7.33338 4.4528 9 6 9C7.5472 9 9 7.33338 9 5C9 2.66664 7.5472 1 6 1C4.4528 1 3 2.66664 3 5ZM10 5C10 7.63722 8.3188 10 6 10H4C1.6812 10 0 7.63722 0 5C0 2.3628 1.6812 0 4 0H6C8.3188 0 10 2.3628 10 5Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M6.71519 4.09259L6.45385 3.18667C6.42141 3.07421 6.34037 3 6.25 3C6.15963 3 6.07859 3.07421 6.04615 3.18667L5.78481 4.09259C5.74675 4.22464 5.66849 4.32899 5.56945 4.37978L4.88999 4.7282C4.80565 4.77146 4.75 4.87951 4.75 5C4.75 5.12049 4.80565 5.22854 4.88999 5.2718L5.56945 5.62022C5.66849 5.67101 5.74675 5.77536 5.78481 5.90741L6.04615 6.81333C6.07859 6.92579 6.15963 7 6.25 7C6.34037 7 6.42141 6.92579 6.45385 6.81333L6.71519 5.90741C6.75325 5.77536 6.83151 5.67101 6.93055 5.62022L7.61001 5.2718C7.69435 5.22854 7.75 5.12049 7.75 5C7.75 4.87951 7.69435 4.77146 7.61001 4.7282L6.93055 4.37978C6.83151 4.32899 6.75325 4.22464 6.71519 4.09259Z",
"fill": "currentColor"
},
"children": []
}
]
},
"name": "CreditsCoin"
}

View File

@@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './CreditsCoin.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'CreditsCoin'
export default Icon

View File

@@ -1,6 +1,5 @@
export { default as Balance } from './Balance'
export { default as CoinsStacked01 } from './CoinsStacked01'
export { default as CreditsCoin } from './CreditsCoin'
export { default as GoldCoin } from './GoldCoin'
export { default as ReceiptList } from './ReceiptList'
export { default as Tag01 } from './Tag01'

View File

@@ -1,14 +1,10 @@
import type { ChangeEvent, FC, KeyboardEvent } from 'react'
import { useCallback, useState } from 'react'
import _AutosizeInput from 'react-18-input-autosize'
import AutosizeInput from 'react-18-input-autosize'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast/context'
import { cn } from '@/utils/classnames'
// CJS/ESM interop: Turbopack may resolve the module namespace object instead of the default export
// eslint-disable-next-line ts/no-explicit-any
const AutosizeInput = ('default' in (_AutosizeInput as any) ? (_AutosizeInput as any).default : _AutosizeInput) as typeof _AutosizeInput
type TagInputProps = {
items: string[]
onChange: (items: string[]) => void

View File

@@ -1,12 +1,12 @@
'use client'
import type { ReactNode } from 'react'
import type { IToastProps } from './context'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import ActionButton from '@/app/components/base/action-button'
import { cn } from '@/utils/classnames'
import type { IToastProps } from './context'
import { ToastContext, useToastContext } from './context'
export type ToastHandle = {

View File

@@ -43,24 +43,20 @@ type DialogContentProps = {
children: React.ReactNode
className?: string
overlayClassName?: string
backdropProps?: React.ComponentPropsWithoutRef<typeof BaseDialog.Backdrop>
}
export function DialogContent({
children,
className,
overlayClassName,
backdropProps,
}: DialogContentProps) {
return (
<DialogPortal>
<BaseDialog.Backdrop
{...backdropProps}
className={cn(
'fixed inset-0 z-50 bg-background-overlay',
'transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
overlayClassName,
backdropProps?.className,
)}
/>
<BaseDialog.Popup

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { CategoryEnum } from '..'
import Footer from '../footer'
import { CategoryEnum } from '../types'
vi.mock('next/link', () => ({
default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (

View File

@@ -1,16 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { Dialog } from '@/app/components/base/ui/dialog'
import Header from '../header'
function renderHeader(onClose: () => void) {
return render(
<Dialog open>
<Header onClose={onClose} />
</Dialog>,
)
}
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -20,7 +11,7 @@ describe('Header', () => {
it('should render title and description translations', () => {
const handleClose = vi.fn()
renderHeader(handleClose)
render(<Header onClose={handleClose} />)
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument()
@@ -31,7 +22,7 @@ describe('Header', () => {
describe('Props', () => {
it('should invoke onClose when close button is clicked', () => {
const handleClose = vi.fn()
renderHeader(handleClose)
render(<Header onClose={handleClose} />)
fireEvent.click(screen.getByRole('button'))
@@ -41,7 +32,7 @@ describe('Header', () => {
describe('Edge Cases', () => {
it('should render structural elements with translation keys', () => {
const { container } = renderHeader(vi.fn())
const { container } = render(<Header onClose={vi.fn()} />)
expect(container.querySelector('span')).toBeInTheDocument()
expect(container.querySelector('p')).toBeInTheDocument()

View File

@@ -74,11 +74,15 @@ describe('Pricing', () => {
})
describe('Props', () => {
it('should allow switching categories', () => {
render(<Pricing onCancel={vi.fn()} />)
it('should allow switching categories and handle esc key', () => {
const handleCancel = vi.fn()
render(<Pricing onCancel={handleCancel} />)
fireEvent.click(screen.getByText('billing.plansCommon.self'))
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
expect(handleCancel).toHaveBeenCalled()
})
})

View File

@@ -1,9 +1,10 @@
import type { Category } from './types'
import type { Category } from '.'
import { RiArrowRightUpLine } from '@remixicon/react'
import Link from 'next/link'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import { CategoryEnum } from './types'
import { CategoryEnum } from '.'
type FooterProps = {
pricingPageURL: string
@@ -33,7 +34,7 @@ const Footer = ({
>
{t('plansCommon.comparePlanAndFeatures', { ns: 'billing' })}
</Link>
<span aria-hidden="true" className="i-ri-arrow-right-up-line size-4" />
<RiArrowRightUpLine className="size-4" />
</span>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { DialogDescription, DialogTitle } from '@/app/components/base/ui/dialog'
import Button from '../../base/button'
import DifyLogo from '../../base/logo/dify-logo'
@@ -20,19 +20,19 @@ const Header = ({
<div className="py-[5px]">
<DifyLogo className="h-[27px] w-[60px]" />
</div>
<DialogTitle className="m-0 bg-billing-plan-title-bg bg-clip-text px-1.5 font-instrument text-[37px] italic leading-[1.2] text-transparent">
<span className="bg-billing-plan-title-bg bg-clip-text px-1.5 font-instrument text-[37px] italic leading-[1.2] text-transparent">
{t('plansCommon.title.plans', { ns: 'billing' })}
</DialogTitle>
</span>
</div>
<DialogDescription className="m-0 text-text-tertiary system-sm-regular">
<p className="system-sm-regular text-text-tertiary">
{t('plansCommon.title.description', { ns: 'billing' })}
</DialogDescription>
</p>
<Button
variant="secondary"
className="absolute bottom-[40.5px] right-[-18px] z-10 size-9 rounded-full p-2"
onClick={onClose}
>
<span aria-hidden="true" className="i-ri-close-line size-5" />
<RiCloseLine className="size-5" />
</Button>
</div>
</div>

View File

@@ -1,9 +1,9 @@
'use client'
import type { FC } from 'react'
import type { Category } from './types'
import { useKeyPress } from 'ahooks'
import * as React from 'react'
import { useState } from 'react'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import { createPortal } from 'react-dom'
import { useAppContext } from '@/context/app-context'
import { useGetPricingPageLanguage } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
@@ -13,7 +13,13 @@ import Header from './header'
import PlanSwitcher from './plan-switcher'
import { PlanRange } from './plan-switcher/plan-range-switcher'
import Plans from './plans'
import { CategoryEnum } from './types'
export enum CategoryEnum {
CLOUD = 'cloud',
SELF = 'self',
}
export type Category = CategoryEnum.CLOUD | CategoryEnum.SELF
type PricingProps = {
onCancel: () => void
@@ -27,47 +33,42 @@ const Pricing: FC<PricingProps> = ({
const [planRange, setPlanRange] = React.useState<PlanRange>(PlanRange.monthly)
const [currentCategory, setCurrentCategory] = useState<Category>(CategoryEnum.CLOUD)
const canPay = isCurrentWorkspaceManager
useKeyPress(['esc'], onCancel)
const pricingPageLanguage = useGetPricingPageLanguage()
const pricingPageURL = pricingPageLanguage
? `https://dify.ai/${pricingPageLanguage}/pricing#plans-and-features`
: 'https://dify.ai/pricing#plans-and-features'
return (
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
return createPortal(
<div
className="fixed inset-0 bottom-0 left-0 right-0 top-0 z-[1000] overflow-auto bg-saas-background"
onClick={e => e.stopPropagation()}
>
<DialogContent
className="inset-0 h-full max-h-none w-full max-w-none translate-x-0 translate-y-0 overflow-auto rounded-none border-none bg-saas-background p-0 shadow-none"
>
<div className="relative grid min-h-full min-w-[1200px] grid-rows-[1fr_auto_auto_1fr] overflow-hidden">
<div className="absolute -top-12 left-0 right-0 -z-10">
<NoiseTop />
</div>
<Header onClose={onCancel} />
<PlanSwitcher
currentCategory={currentCategory}
onChangeCategory={setCurrentCategory}
currentPlanRange={planRange}
onChangePlanRange={setPlanRange}
/>
<Plans
plan={plan}
currentPlan={currentCategory}
planRange={planRange}
canPay={canPay}
/>
<Footer pricingPageURL={pricingPageURL} currentCategory={currentCategory} />
<div className="absolute -bottom-12 left-0 right-0 -z-10">
<NoiseBottom />
</div>
<div className="relative grid min-h-full min-w-[1200px] grid-rows-[1fr_auto_auto_1fr] overflow-hidden">
<div className="absolute -top-12 left-0 right-0 -z-10">
<NoiseTop />
</div>
</DialogContent>
</Dialog>
<Header onClose={onCancel} />
<PlanSwitcher
currentCategory={currentCategory}
onChangeCategory={setCurrentCategory}
currentPlanRange={planRange}
onChangePlanRange={setPlanRange}
/>
<Plans
plan={plan}
currentPlan={currentCategory}
planRange={planRange}
canPay={canPay}
/>
<Footer pricingPageURL={pricingPageURL} currentCategory={currentCategory} />
<div className="absolute -bottom-12 left-0 right-0 -z-10">
<NoiseBottom />
</div>
</div>
</div>,
document.body,
)
}
export default React.memo(Pricing)

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { CategoryEnum } from '../../types'
import { CategoryEnum } from '../../index'
import PlanSwitcher from '../index'
import { PlanRange } from '../plan-range-switcher'

View File

@@ -1,5 +1,5 @@
import type { FC } from 'react'
import type { Category } from '../types'
import type { Category } from '../index'
import type { PlanRange } from './plan-range-switcher'
import * as React from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -1,6 +0,0 @@
export enum CategoryEnum {
CLOUD = 'cloud',
SELF = 'self',
}
export type Category = CategoryEnum.CLOUD | CategoryEnum.SELF

View File

@@ -204,7 +204,7 @@ const CSVUploader: FC<Props> = ({
/>
<div ref={dropRef}>
{!file && (
<div className={cn('flex h-20 items-center rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur text-sm font-normal', dragging && 'border border-divider-subtle bg-components-panel-on-panel-item-bg-hover')}>
<div className={cn('flex h-20 items-center rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur text-sm font-normal', dragging && 'border border-divider-subtle bg-components-panel-on-panel-item-bg-hover')}>
<div className="flex w-full items-center justify-center space-x-2">
<CSVIcon className="shrink-0" />
<div className="text-text-secondary">

View File

@@ -58,7 +58,7 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({
<Divider type="vertical" className="mx-1 h-3 bg-divider-regular" />
<button
type="button"
className="text-text-accent system-xs-semibold"
className="system-xs-semibold text-text-accent"
onClick={() => {
clearTimeout(refreshTimer.current)
viewNewlyAddedChildChunk?.()
@@ -120,11 +120,11 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({
<div className="flex h-full flex-col">
<div className={cn('flex items-center justify-between', fullScreen ? 'border border-divider-subtle py-3 pl-6 pr-4' : 'pl-4 pr-3 pt-3')}>
<div className="flex flex-col">
<div className="text-text-primary system-xl-semibold">{t('segment.addChildChunk', { ns: 'datasetDocuments' })}</div>
<div className="system-xl-semibold text-text-primary">{t('segment.addChildChunk', { ns: 'datasetDocuments' })}</div>
<div className="flex items-center gap-x-2">
<SegmentIndexTag label={t('segment.newChildChunk', { ns: 'datasetDocuments' }) as string} />
<Dot />
<span className="text-text-tertiary system-xs-medium">{wordCountText}</span>
<span className="system-xs-medium text-text-tertiary">{wordCountText}</span>
</div>
</div>
<div className="flex items-center">

View File

@@ -61,7 +61,7 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
<Divider type="vertical" className="mx-1 h-3 bg-divider-regular" />
<button
type="button"
className="text-text-accent system-xs-semibold"
className="system-xs-semibold text-text-accent"
onClick={() => {
clearTimeout(refreshTimer.current)
viewNewlyAddedChunk()
@@ -158,13 +158,13 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
className={cn('flex items-center justify-between', fullScreen ? 'border border-divider-subtle py-3 pl-6 pr-4' : 'pl-4 pr-3 pt-3')}
>
<div className="flex flex-col">
<div className="text-text-primary system-xl-semibold">
<div className="system-xl-semibold text-text-primary">
{t('segment.addChunk', { ns: 'datasetDocuments' })}
</div>
<div className="flex items-center gap-x-2">
<SegmentIndexTag label={t('segment.newChunk', { ns: 'datasetDocuments' })!} />
<Dot />
<span className="text-text-tertiary system-xs-medium">{wordCountText}</span>
<span className="system-xs-medium text-text-tertiary">{wordCountText}</span>
</div>
</div>
<div className="flex items-center">

View File

@@ -100,10 +100,10 @@ vi.mock('@/app/components/datasets/create/step-two', () => ({
}))
vi.mock('@/app/components/header/account-setting', () => ({
default: ({ activeTab, onCancelAction }: { activeTab?: string, onCancelAction?: () => void }) => (
default: ({ activeTab, onCancel }: { activeTab?: string, onCancel?: () => void }) => (
<div data-testid="account-setting">
<span data-testid="active-tab">{activeTab}</span>
<button onClick={onCancelAction} data-testid="close-setting">Close</button>
<button onClick={onCancel} data-testid="close-setting">Close</button>
</div>
),
}))

View File

@@ -1,4 +1,3 @@
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
import type { DataSourceProvider, NotionPage } from '@/models/common'
import type {
CrawlOptions,
@@ -20,7 +19,6 @@ import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import StepTwo from '@/app/components/datasets/create/step-two'
import AccountSetting from '@/app/components/header/account-setting'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import DatasetDetailContext from '@/context/dataset-detail'
@@ -35,13 +33,8 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
const { t } = useTranslation()
const router = useRouter()
const [isShowSetAPIKey, { setTrue: showSetAPIKey, setFalse: hideSetAPIkey }] = useBoolean()
const [accountSettingTab, setAccountSettingTab] = React.useState<AccountSettingTab>(ACCOUNT_SETTING_TAB.PROVIDER)
const { indexingTechnique, dataset } = useContext(DatasetDetailContext)
const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
const handleOpenAccountSetting = React.useCallback(() => {
setAccountSettingTab(ACCOUNT_SETTING_TAB.PROVIDER)
showSetAPIKey()
}, [showSetAPIKey])
const invalidDocumentList = useInvalidDocumentList(datasetId)
const invalidDocumentDetail = useInvalidDocumentDetail()
@@ -142,7 +135,7 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
{dataset && documentDetail && (
<StepTwo
isAPIKeySet={!!embeddingsDefaultModel}
onSetting={handleOpenAccountSetting}
onSetting={showSetAPIKey}
datasetId={datasetId}
dataSourceType={documentDetail.data_source_type as DataSourceType}
notionPages={currentPage ? [currentPage as unknown as NotionPage] : []}
@@ -162,9 +155,8 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
</div>
{isShowSetAPIKey && (
<AccountSetting
activeTab={accountSettingTab}
onTabChangeAction={setAccountSettingTab}
onCancelAction={async () => {
activeTab="provider"
onCancel={async () => {
hideSetAPIkey()
}}
/>

View File

@@ -120,13 +120,13 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
<div className="fixed inset-0 flex items-center justify-center bg-black/[.25]">
<div className="shadows-shadow-xl relative flex w-[480px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg">
<div className="flex flex-col items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6">
<div className="grow self-stretch text-text-primary title-2xl-semi-bold">
<div className="title-2xl-semi-bold grow self-stretch text-text-primary">
{
isEditMode ? t('editExternalAPIFormTitle', { ns: 'dataset' }) : t('createExternalAPI', { ns: 'dataset' })
}
</div>
{isEditMode && (datasetBindings?.length ?? 0) > 0 && (
<div className="flex items-center text-text-tertiary system-xs-regular">
<div className="system-xs-regular flex items-center text-text-tertiary">
{t('editExternalAPIFormWarning.front', { ns: 'dataset' })}
<span className="flex cursor-pointer items-center text-text-accent">
&nbsp;
@@ -139,12 +139,12 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
popupContent={(
<div className="p-1">
<div className="flex items-start self-stretch pb-0.5 pl-2 pr-3 pt-1">
<div className="text-text-tertiary system-xs-medium-uppercase">{`${datasetBindings?.length} ${t('editExternalAPITooltipTitle', { ns: 'dataset' })}`}</div>
<div className="system-xs-medium-uppercase text-text-tertiary">{`${datasetBindings?.length} ${t('editExternalAPITooltipTitle', { ns: 'dataset' })}`}</div>
</div>
{datasetBindings?.map(binding => (
<div key={binding.id} className="flex items-center gap-1 self-stretch px-2 py-1">
<RiBook2Line className="h-4 w-4 text-text-secondary" />
<div className="text-text-secondary system-sm-medium">{binding.name}</div>
<div className="system-sm-medium text-text-secondary">{binding.name}</div>
</div>
))}
</div>
@@ -188,8 +188,8 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
{t('externalAPIForm.save', { ns: 'dataset' })}
</Button>
</div>
<div className="flex items-center justify-center gap-1 self-stretch rounded-b-2xl border-t-[0.5px] border-divider-subtle
bg-background-soft px-2 py-3 text-text-tertiary system-xs-regular"
<div className="system-xs-regular flex items-center justify-center gap-1 self-stretch rounded-b-2xl border-t-[0.5px]
border-divider-subtle bg-background-soft px-2 py-3 text-text-tertiary"
>
<RiLock2Fill className="h-3 w-3 text-text-quaternary" />
{t('externalAPIForm.encrypted.front', { ns: 'dataset' })}

View File

@@ -46,7 +46,7 @@ const WorkplaceSelector = () => {
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle font-semibold uppercase leading-6 text-shadow-shadow-1 opacity-90">{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
</div>
<div className="flex min-w-0 items-center">
<div className="min-w-0 max-w-[149px] truncate text-text-secondary system-sm-medium max-[800px]:hidden">{currentWorkspace?.name}</div>
<div className="system-sm-medium min-w-0 max-w-[149px] truncate text-text-secondary max-[800px]:hidden">{currentWorkspace?.name}</div>
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-text-secondary" />
</div>
</MenuButton>
@@ -68,9 +68,9 @@ const WorkplaceSelector = () => {
`,
)}
>
<div className="flex w-full flex-col items-start self-stretch rounded-xl border-[0.5px] border-components-panel-border p-1 pb-2 shadow-lg">
<div className="flex w-full flex-col items-start self-stretch rounded-xl border-[0.5px] border-components-panel-border p-1 pb-2 shadow-lg ">
<div className="flex items-start self-stretch px-3 pb-0.5 pt-1">
<span className="flex-1 text-text-tertiary system-xs-medium-uppercase">{t('userProfile.workspace', { ns: 'common' })}</span>
<span className="system-xs-medium-uppercase flex-1 text-text-tertiary">{t('userProfile.workspace', { ns: 'common' })}</span>
</div>
{
workspaces.map(workspace => (
@@ -78,7 +78,7 @@ const WorkplaceSelector = () => {
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle font-semibold uppercase leading-6 text-shadow-shadow-1 opacity-90">{workspace?.name[0]?.toLocaleUpperCase()}</span>
</div>
<div className="line-clamp-1 grow cursor-pointer overflow-hidden text-ellipsis text-text-secondary system-md-regular">{workspace.name}</div>
<div className="system-md-regular line-clamp-1 grow cursor-pointer overflow-hidden text-ellipsis text-text-secondary">{workspace.name}</div>
<PlanBadge plan={workspace.plan as Plan} />
</div>
))

View File

@@ -1,16 +1,12 @@
import type { AccountSettingTab } from './constants'
import type { AppContextValue } from '@/context/app-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { ACCOUNT_SETTING_TAB } from './constants'
import AccountSetting from './index'
const mockResetModelProviderListExpanded = vi.fn()
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
@@ -51,15 +47,10 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
useDefaultModel: vi.fn(() => ({ data: null, isLoading: false })),
useUpdateDefaultModel: vi.fn(() => ({ trigger: vi.fn() })),
useUpdateModelList: vi.fn(() => vi.fn()),
useInvalidateDefaultModel: vi.fn(() => vi.fn()),
useModelList: vi.fn(() => ({ data: [], isLoading: false })),
useSystemDefaultModelAndModelList: vi.fn(() => [null, vi.fn()]),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/atoms', () => ({
useResetModelProviderListExpanded: () => mockResetModelProviderListExpanded,
}))
vi.mock('@/service/use-datasource', () => ({
useGetDataSourceListAuth: vi.fn(() => ({ data: { result: [] } })),
}))
@@ -114,38 +105,6 @@ const baseAppContextValue: AppContextValue = {
describe('AccountSetting', () => {
const mockOnCancel = vi.fn()
const mockOnTabChange = vi.fn()
const renderAccountSetting = (props?: {
initialTab?: AccountSettingTab
onCancel?: () => void
onTabChange?: (tab: AccountSettingTab) => void
}) => {
const {
initialTab = ACCOUNT_SETTING_TAB.MEMBERS,
onCancel = mockOnCancel,
onTabChange = mockOnTabChange,
} = props ?? {}
const StatefulAccountSetting = () => {
const [activeTab, setActiveTab] = useState<AccountSettingTab>(initialTab)
return (
<AccountSetting
onCancelAction={onCancel}
activeTab={activeTab}
onTabChangeAction={(tab) => {
setActiveTab(tab)
onTabChange(tab)
}}
/>
)
}
return render(
<QueryClientProvider client={new QueryClient()}>
<StatefulAccountSetting />
</QueryClientProvider>,
)
}
beforeEach(() => {
vi.clearAllMocks()
@@ -161,7 +120,11 @@ describe('AccountSetting', () => {
describe('Rendering', () => {
it('should render the sidebar with correct menu items', () => {
// Act
renderAccountSetting()
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Assert
expect(screen.getByText('common.userProfile.settings')).toBeInTheDocument()
@@ -174,9 +137,13 @@ describe('AccountSetting', () => {
expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(0)
})
it('should respect the initial tab', () => {
it('should respect the activeTab prop', () => {
// Act
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.DATA_SOURCE })
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} activeTab={ACCOUNT_SETTING_TAB.DATA_SOURCE} />
</QueryClientProvider>,
)
// Assert
// Check that the active item title is Data Source
@@ -190,7 +157,11 @@ describe('AccountSetting', () => {
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
// Act
renderAccountSetting()
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Assert
// On mobile, the labels should not be rendered as per the implementation
@@ -205,7 +176,11 @@ describe('AccountSetting', () => {
})
// Act
renderAccountSetting()
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Assert
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
@@ -222,7 +197,11 @@ describe('AccountSetting', () => {
})
// Act
renderAccountSetting()
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Assert
expect(screen.queryByText('common.settings.billing')).not.toBeInTheDocument()
@@ -233,7 +212,11 @@ describe('AccountSetting', () => {
describe('Tab Navigation', () => {
it('should change active tab when clicking on menu item', () => {
// Arrange
renderAccountSetting({ onTabChange: mockOnTabChange })
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} onTabChange={mockOnTabChange} />
</QueryClientProvider>,
)
// Act
fireEvent.click(screen.getByText('common.settings.provider'))
@@ -246,7 +229,11 @@ describe('AccountSetting', () => {
it('should navigate through various tabs and show correct details', () => {
// Act & Assert
renderAccountSetting()
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Billing
fireEvent.click(screen.getByText('common.settings.billing'))
@@ -280,11 +267,13 @@ describe('AccountSetting', () => {
describe('Interactions', () => {
it('should call onCancel when clicking close button', () => {
// Act
renderAccountSetting()
const closeIcon = document.querySelector('.i-ri-close-line')
const closeButton = closeIcon?.closest('button')
expect(closeButton).not.toBeNull()
fireEvent.click(closeButton!)
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
// Assert
expect(mockOnCancel).toHaveBeenCalled()
@@ -292,7 +281,11 @@ describe('AccountSetting', () => {
it('should call onCancel when pressing Escape key', () => {
// Act
renderAccountSetting()
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
fireEvent.keyDown(document, { key: 'Escape' })
// Assert
@@ -301,7 +294,12 @@ describe('AccountSetting', () => {
it('should update search value in provider tab', () => {
// Arrange
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.PROVIDER })
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
fireEvent.click(screen.getByText('common.settings.provider'))
// Act
const input = screen.getByRole('textbox')
@@ -314,7 +312,11 @@ describe('AccountSetting', () => {
it('should handle scroll event in panel', () => {
// Act
renderAccountSetting()
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
const scrollContainer = screen.getByRole('dialog').querySelector('.overflow-y-auto')
// Assert

View File

@@ -1,6 +1,6 @@
'use client'
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SearchInput from '@/app/components/base/search-input'
import BillingPage from '@/app/components/billing/billing-page'
@@ -20,16 +20,15 @@ import DataSourcePage from './data-source-page-new'
import LanguagePage from './language-page'
import MembersPage from './members-page'
import ModelProviderPage from './model-provider-page'
import { useResetModelProviderListExpanded } from './model-provider-page/atoms'
const iconClassName = `
w-5 h-5 mr-2
`
type IAccountSettingProps = {
onCancelAction: () => void
activeTab: AccountSettingTab
onTabChangeAction: (tab: AccountSettingTab) => void
onCancel: () => void
activeTab?: AccountSettingTab
onTabChange?: (tab: AccountSettingTab) => void
}
type GroupItem = {
@@ -41,12 +40,14 @@ type GroupItem = {
}
export default function AccountSetting({
onCancelAction,
activeTab,
onTabChangeAction,
onCancel,
activeTab = ACCOUNT_SETTING_TAB.MEMBERS,
onTabChange,
}: IAccountSettingProps) {
const resetModelProviderListExpanded = useResetModelProviderListExpanded()
const activeMenu = activeTab
const [activeMenu, setActiveMenu] = useState<AccountSettingTab>(activeTab)
useEffect(() => {
setActiveMenu(activeTab)
}, [activeTab])
const { t } = useTranslation()
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
@@ -147,22 +148,10 @@ export default function AccountSetting({
const [searchValue, setSearchValue] = useState<string>('')
const handleTabChange = useCallback((tab: AccountSettingTab) => {
if (tab === ACCOUNT_SETTING_TAB.PROVIDER)
resetModelProviderListExpanded()
onTabChangeAction(tab)
}, [onTabChangeAction, resetModelProviderListExpanded])
const handleClose = useCallback(() => {
resetModelProviderListExpanded()
onCancelAction()
}, [onCancelAction, resetModelProviderListExpanded])
return (
<MenuDialog
show
onClose={handleClose}
onClose={onCancel}
>
<div className="mx-auto flex h-[100vh] max-w-[1048px]">
<div className="flex w-[44px] flex-col border-r border-divider-burn pl-4 pr-6 sm:w-[224px]">
@@ -177,22 +166,21 @@ export default function AccountSetting({
<div>
{
menuItem.items.map(item => (
<button
type="button"
<div
key={item.key}
className={cn(
'mb-0.5 flex h-[37px] w-full items-center rounded-lg p-1 pl-3 text-left text-sm',
'mb-0.5 flex h-[37px] cursor-pointer items-center rounded-lg p-1 pl-3 text-sm',
activeMenu === item.key ? 'bg-state-base-active text-components-menu-item-text-active system-sm-semibold' : 'text-components-menu-item-text system-sm-medium',
)}
aria-label={item.name}
title={item.name}
onClick={() => {
handleTabChange(item.key)
setActiveMenu(item.key)
onTabChange?.(item.key)
}}
>
{activeMenu === item.key ? item.activeIcon : item.icon}
{!isMobile && <div className="truncate">{item.name}</div>}
</button>
</div>
))
}
</div>
@@ -207,8 +195,7 @@ export default function AccountSetting({
variant="tertiary"
size="large"
className="px-2"
aria-label={t('operation.close', { ns: 'common' })}
onClick={handleClose}
onClick={onCancel}
>
<span className="i-ri-close-line h-5 w-5" />
</Button>

View File

@@ -97,7 +97,7 @@ const Operation = ({
offset={{ mainAxis: 4 }}
>
<PortalToFollowElemTrigger asChild onClick={() => setOpen(prev => !prev)}>
<div className={cn('group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary system-sm-regular hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
<div className={cn('system-sm-regular group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
{RoleMap[member.role] || RoleMap.normal}
<ChevronDownIcon className={cn('h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
</div>
@@ -114,8 +114,8 @@ const Operation = ({
: <div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
}
<div>
<div className="whitespace-nowrap text-text-secondary system-sm-semibold">{t(roleI18nKeyMap[role].label, { ns: 'common' })}</div>
<div className="whitespace-nowrap text-text-tertiary system-xs-regular">{t(roleI18nKeyMap[role].tip, { ns: 'common' })}</div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(roleI18nKeyMap[role].label, { ns: 'common' })}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(roleI18nKeyMap[role].tip, { ns: 'common' })}</div>
</div>
</div>
))
@@ -125,8 +125,8 @@ const Operation = ({
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleDeleteMemberOrCancelInvitation}>
<div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
<div>
<div className="whitespace-nowrap text-text-secondary system-sm-semibold">{t('members.removeFromTeam', { ns: 'common' })}</div>
<div className="whitespace-nowrap text-text-tertiary system-xs-regular">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('members.removeFromTeam', { ns: 'common' })}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
</div>
</div>
</div>

View File

@@ -40,7 +40,8 @@ describe('MenuDialog', () => {
)
// Assert
expect(screen.getByRole('dialog')).toHaveClass('custom-class')
const panel = screen.getByRole('dialog').querySelector('.custom-class')
expect(panel).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import { useCallback } from 'react'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
import { noop } from 'es-toolkit/function'
import { Fragment, useCallback, useEffect } from 'react'
import { cn } from '@/utils/classnames'
type DialogProps = {
@@ -18,25 +19,42 @@ const MenuDialog = ({
}: DialogProps) => {
const close = useCallback(() => onClose?.(), [onClose])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
close()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [close])
return (
<Dialog
open={show}
onOpenChange={(open) => {
if (!open)
close()
}}
>
<DialogContent
overlayClassName="bg-transparent"
className={cn(
'left-0 top-0 h-full max-h-none w-full max-w-none translate-x-0 translate-y-0 overflow-hidden rounded-none border-none bg-background-sidenav-bg p-0 shadow-none backdrop-blur-md',
className,
)}
>
<div className="absolute right-0 top-0 h-full w-1/2 bg-components-panel-bg" />
{children}
</DialogContent>
</Dialog>
<Transition appear show={show} as={Fragment}>
<Dialog as="div" className="relative z-[60]" onClose={noop}>
<div className="fixed inset-0">
<div className="flex min-h-full flex-col items-center justify-center">
<TransitionChild>
<DialogPanel className={cn(
'relative h-full w-full grow overflow-hidden bg-background-sidenav-bg p-0 text-left align-middle backdrop-blur-md transition-all',
'duration-300 ease-in data-[closed]:scale-95 data-[closed]:opacity-0',
'data-[enter]:scale-100 data-[enter]:opacity-100',
'data-[enter]:scale-95 data-[leave]:opacity-0',
className,
)}
>
<div className="absolute right-0 top-0 h-full w-1/2 bg-components-panel-bg" />
{children}
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
)
}

View File

@@ -1,399 +0,0 @@
import type { ReactNode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { Provider } from 'jotai'
import { beforeEach, describe, expect, it } from 'vitest'
import {
useExpandModelProviderList,
useModelProviderListExpanded,
useResetModelProviderListExpanded,
useSetModelProviderListExpanded,
} from './atoms'
const createWrapper = () => {
return ({ children }: { children: ReactNode }) => (
<Provider>{children}</Provider>
)
}
describe('atoms', () => {
let wrapper: ReturnType<typeof createWrapper>
beforeEach(() => {
wrapper = createWrapper()
})
// Read hook: returns whether a specific provider is expanded
describe('useModelProviderListExpanded', () => {
it('should return false when provider has not been expanded', () => {
const { result } = renderHook(
() => useModelProviderListExpanded('openai'),
{ wrapper },
)
expect(result.current).toBe(false)
})
it('should return false for any unknown provider name', () => {
const { result } = renderHook(
() => useModelProviderListExpanded('nonexistent-provider'),
{ wrapper },
)
expect(result.current).toBe(false)
})
it('should return true when provider has been expanded via setter', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
setExpanded: useSetModelProviderListExpanded('openai'),
}),
{ wrapper },
)
act(() => {
result.current.setExpanded(true)
})
expect(result.current.expanded).toBe(true)
})
})
// Setter hook: toggles expanded state for a specific provider
describe('useSetModelProviderListExpanded', () => {
it('should expand a provider when called with true', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('anthropic'),
setExpanded: useSetModelProviderListExpanded('anthropic'),
}),
{ wrapper },
)
act(() => {
result.current.setExpanded(true)
})
expect(result.current.expanded).toBe(true)
})
it('should collapse a provider when called with false', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('anthropic'),
setExpanded: useSetModelProviderListExpanded('anthropic'),
}),
{ wrapper },
)
act(() => {
result.current.setExpanded(true)
})
act(() => {
result.current.setExpanded(false)
})
expect(result.current.expanded).toBe(false)
})
it('should not affect other providers when setting one', () => {
const { result } = renderHook(
() => ({
openaiExpanded: useModelProviderListExpanded('openai'),
anthropicExpanded: useModelProviderListExpanded('anthropic'),
setOpenai: useSetModelProviderListExpanded('openai'),
}),
{ wrapper },
)
act(() => {
result.current.setOpenai(true)
})
expect(result.current.openaiExpanded).toBe(true)
expect(result.current.anthropicExpanded).toBe(false)
})
})
// Expand hook: expands any provider by name
describe('useExpandModelProviderList', () => {
it('should expand the specified provider', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('google'),
expand: useExpandModelProviderList(),
}),
{ wrapper },
)
act(() => {
result.current.expand('google')
})
expect(result.current.expanded).toBe(true)
})
it('should expand multiple providers independently', () => {
const { result } = renderHook(
() => ({
openaiExpanded: useModelProviderListExpanded('openai'),
anthropicExpanded: useModelProviderListExpanded('anthropic'),
expand: useExpandModelProviderList(),
}),
{ wrapper },
)
act(() => {
result.current.expand('openai')
})
act(() => {
result.current.expand('anthropic')
})
expect(result.current.openaiExpanded).toBe(true)
expect(result.current.anthropicExpanded).toBe(true)
})
it('should not collapse already expanded providers when expanding another', () => {
const { result } = renderHook(
() => ({
openaiExpanded: useModelProviderListExpanded('openai'),
anthropicExpanded: useModelProviderListExpanded('anthropic'),
expand: useExpandModelProviderList(),
}),
{ wrapper },
)
act(() => {
result.current.expand('openai')
})
act(() => {
result.current.expand('anthropic')
})
expect(result.current.openaiExpanded).toBe(true)
})
})
// Reset hook: clears all expanded state back to empty
describe('useResetModelProviderListExpanded', () => {
it('should reset all expanded providers to false', () => {
const { result } = renderHook(
() => ({
openaiExpanded: useModelProviderListExpanded('openai'),
anthropicExpanded: useModelProviderListExpanded('anthropic'),
expand: useExpandModelProviderList(),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.expand('openai')
})
act(() => {
result.current.expand('anthropic')
})
act(() => {
result.current.reset()
})
expect(result.current.openaiExpanded).toBe(false)
expect(result.current.anthropicExpanded).toBe(false)
})
it('should be safe to call when no providers are expanded', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.reset()
})
expect(result.current.expanded).toBe(false)
})
it('should allow re-expanding providers after reset', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
expand: useExpandModelProviderList(),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.expand('openai')
})
act(() => {
result.current.reset()
})
act(() => {
result.current.expand('openai')
})
expect(result.current.expanded).toBe(true)
})
})
// Cross-hook interaction: verify hooks cooperate through the shared atom
describe('Cross-hook interaction', () => {
it('should reflect state set by useSetModelProviderListExpanded in useModelProviderListExpanded', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
setExpanded: useSetModelProviderListExpanded('openai'),
}),
{ wrapper },
)
act(() => {
result.current.setExpanded(true)
})
expect(result.current.expanded).toBe(true)
})
it('should reflect state set by useExpandModelProviderList in useModelProviderListExpanded', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('anthropic'),
expand: useExpandModelProviderList(),
}),
{ wrapper },
)
act(() => {
result.current.expand('anthropic')
})
expect(result.current.expanded).toBe(true)
})
it('should allow useSetModelProviderListExpanded to collapse a provider expanded by useExpandModelProviderList', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
expand: useExpandModelProviderList(),
setExpanded: useSetModelProviderListExpanded('openai'),
}),
{ wrapper },
)
act(() => {
result.current.expand('openai')
})
expect(result.current.expanded).toBe(true)
act(() => {
result.current.setExpanded(false)
})
expect(result.current.expanded).toBe(false)
})
it('should reset state set by useSetModelProviderListExpanded via useResetModelProviderListExpanded', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
setExpanded: useSetModelProviderListExpanded('openai'),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.setExpanded(true)
})
act(() => {
result.current.reset()
})
expect(result.current.expanded).toBe(false)
})
})
// selectAtom granularity: changing one provider should not affect unrelated reads
describe('selectAtom granularity', () => {
it('should not cause unrelated provider reads to change when one provider is toggled', () => {
const { result } = renderHook(
() => ({
openai: useModelProviderListExpanded('openai'),
anthropic: useModelProviderListExpanded('anthropic'),
google: useModelProviderListExpanded('google'),
setOpenai: useSetModelProviderListExpanded('openai'),
}),
{ wrapper },
)
const anthropicBefore = result.current.anthropic
const googleBefore = result.current.google
act(() => {
result.current.setOpenai(true)
})
expect(result.current.openai).toBe(true)
expect(result.current.anthropic).toBe(anthropicBefore)
expect(result.current.google).toBe(googleBefore)
})
it('should keep individual provider states independent across multiple expansions and collapses', () => {
const { result } = renderHook(
() => ({
openai: useModelProviderListExpanded('openai'),
anthropic: useModelProviderListExpanded('anthropic'),
setOpenai: useSetModelProviderListExpanded('openai'),
setAnthropic: useSetModelProviderListExpanded('anthropic'),
}),
{ wrapper },
)
act(() => {
result.current.setOpenai(true)
})
act(() => {
result.current.setAnthropic(true)
})
act(() => {
result.current.setOpenai(false)
})
expect(result.current.openai).toBe(false)
expect(result.current.anthropic).toBe(true)
})
})
// Isolation: separate Provider instances have independent state
describe('Provider isolation', () => {
it('should have independent state across different Provider instances', () => {
const wrapper1 = createWrapper()
const wrapper2 = createWrapper()
const { result: result1 } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
setExpanded: useSetModelProviderListExpanded('openai'),
}),
{ wrapper: wrapper1 },
)
const { result: result2 } = renderHook(
() => useModelProviderListExpanded('openai'),
{ wrapper: wrapper2 },
)
act(() => {
result1.current.setExpanded(true)
})
expect(result1.current.expanded).toBe(true)
expect(result2.current).toBe(false)
})
})
})

View File

@@ -1,35 +0,0 @@
import { atom, useAtomValue, useSetAtom } from 'jotai'
import { selectAtom } from 'jotai/utils'
import { useCallback, useMemo } from 'react'
const expandedAtom = atom<Record<string, boolean>>({})
export function useModelProviderListExpanded(providerName: string) {
return useAtomValue(
useMemo(
() => selectAtom(expandedAtom, s => !!s[providerName]),
[providerName],
),
)
}
export function useSetModelProviderListExpanded(providerName: string) {
const set = useSetAtom(expandedAtom)
return useCallback(
(expanded: boolean) => set(prev => ({ ...prev, [providerName]: expanded })),
[providerName, set],
)
}
export function useExpandModelProviderList() {
const set = useSetAtom(expandedAtom)
return useCallback(
(providerName: string) => set(prev => ({ ...prev, [providerName]: true })),
[set],
)
}
export function useResetModelProviderListExpanded() {
const set = useSetAtom(expandedAtom)
return useCallback(() => set({}), [set])
}

View File

@@ -9,7 +9,6 @@ import type {
} from './declarations'
import { act, renderHook, waitFor } from '@testing-library/react'
import { useLocale } from '@/context/i18n'
import { consoleQuery } from '@/service/client'
import { fetchDefaultModal, fetchModelList, fetchModelProviderCredentials } from '@/service/common'
import {
ConfigurationMethodEnum,
@@ -24,7 +23,6 @@ import {
useAnthropicBuyQuota,
useCurrentProviderAndModel,
useDefaultModel,
useInvalidateDefaultModel,
useLanguage,
useMarketplaceAllPlugins,
useModelList,
@@ -38,6 +36,7 @@ import {
useUpdateModelList,
useUpdateModelProviders,
} from './hooks'
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './provider-added-card'
// Mock dependencies
vi.mock('@tanstack/react-query', () => ({
@@ -79,6 +78,14 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: vi.fn(() => ({
eventEmitter: {
emit: vi.fn(),
},
})),
}))
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplacePlugins: vi.fn(() => ({
plugins: [],
@@ -92,16 +99,12 @@ vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
})),
}))
vi.mock('./atoms', () => ({
useExpandModelProviderList: vi.fn(() => vi.fn()),
}))
const { useQuery, useQueryClient } = await import('@tanstack/react-query')
const { getPayUrl } = await import('@/service/common')
const { useProviderContext } = await import('@/context/provider-context')
const { useModalContextSelector } = await import('@/context/modal-context')
const { useEventEmitterContextContext } = await import('@/context/event-emitter')
const { useMarketplacePlugins, useMarketplacePluginsByCollectionId } = await import('@/app/components/plugins/marketplace/hooks')
const { useExpandModelProviderList } = await import('./atoms')
describe('hooks', () => {
beforeEach(() => {
@@ -861,38 +864,6 @@ describe('hooks', () => {
})
})
describe('useInvalidateDefaultModel', () => {
it('should invalidate default model queries', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
const { result } = renderHook(() => useInvalidateDefaultModel())
act(() => {
result.current(ModelTypeEnum.textGeneration)
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['default-model', ModelTypeEnum.textGeneration],
})
})
it('should handle multiple model types', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
const { result } = renderHook(() => useInvalidateDefaultModel())
act(() => {
result.current(ModelTypeEnum.textGeneration)
result.current(ModelTypeEnum.textEmbedding)
result.current(ModelTypeEnum.rerank)
})
expect(invalidateQueries).toHaveBeenCalledTimes(3)
})
})
describe('useAnthropicBuyQuota', () => {
beforeEach(() => {
Object.defineProperty(window, 'location', {
@@ -1196,52 +1167,39 @@ describe('hooks', () => {
it('should refresh providers and model lists', () => {
const invalidateQueries = vi.fn()
const emit = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useEventEmitterContextContext as Mock).mockReturnValue({
eventEmitter: { emit },
})
const provider = createMockProvider()
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: provider.provider,
},
},
})
const { result } = renderHook(() => useRefreshModel())
act(() => {
result.current.handleRefreshModel(provider)
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'none',
})
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-providers'] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textGeneration] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textEmbedding] })
})
it('should expand target provider list when refreshModelList is true and custom config is active', () => {
it('should emit event when refreshModelList is true and custom config is active', () => {
const invalidateQueries = vi.fn()
const expandModelProviderList = vi.fn()
const emit = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList)
; (useEventEmitterContextContext as Mock).mockReturnValue({
eventEmitter: { emit },
})
const provider = createMockProvider()
const customFields: CustomConfigurationModelFixedFields = {
__model_name: 'gpt-4',
__model_type: ModelTypeEnum.textGeneration,
}
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: provider.provider,
},
},
})
const { result } = renderHook(() => useRefreshModel())
@@ -1249,30 +1207,23 @@ describe('hooks', () => {
result.current.handleRefreshModel(provider, customFields, true)
})
expect(expandModelProviderList).toHaveBeenCalledWith('openai')
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'active',
expect(emit).toHaveBeenCalledWith({
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
payload: 'openai',
})
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textGeneration] })
})
it('should not expand provider list when custom config is not active', () => {
it('should not emit event when custom config is not active', () => {
const invalidateQueries = vi.fn()
const expandModelProviderList = vi.fn()
const emit = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList)
; (useEventEmitterContextContext as Mock).mockReturnValue({
eventEmitter: { emit },
})
const provider = { ...createMockProvider(), custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure } }
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: provider.provider,
},
},
})
const { result } = renderHook(() => useRefreshModel())
@@ -1280,43 +1231,16 @@ describe('hooks', () => {
result.current.handleRefreshModel(provider, undefined, true)
})
expect(expandModelProviderList).not.toHaveBeenCalled()
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'active',
})
})
it('should refetch active model provider list when custom refresh callback is absent', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
const provider = createMockProvider()
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: provider.provider,
},
},
})
const { result } = renderHook(() => useRefreshModel())
act(() => {
result.current.handleRefreshModel(provider, undefined, true)
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'active',
})
expect(emit).not.toHaveBeenCalled()
})
it('should handle provider with single model type', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useEventEmitterContextContext as Mock).mockReturnValue({
eventEmitter: { emit: vi.fn() },
})
const provider = {
...createMockProvider(),

View File

@@ -21,10 +21,10 @@ import {
useMarketplacePluginsByCollectionId,
} from '@/app/components/plugins/marketplace/hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useLocale } from '@/context/i18n'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import {
fetchDefaultModal,
fetchModelList,
@@ -32,12 +32,12 @@ import {
getPayUrl,
} from '@/service/common'
import { commonQueryKeys } from '@/service/use-common'
import { useExpandModelProviderList } from './atoms'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
ModelStatusEnum,
} from './declarations'
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './provider-added-card'
type UseDefaultModelAndModelList = (
defaultModel: DefaultModelResponse | undefined,
@@ -57,21 +57,15 @@ export const useSystemDefaultModelAndModelList: UseDefaultModelAndModelList = (
return currentDefaultModel
}, [defaultModel, modelList])
const currentDefaultModelKey = currentDefaultModel
? `${currentDefaultModel.provider}:${currentDefaultModel.model}`
: ''
const [defaultModelState, setDefaultModelState] = useState<DefaultModel | undefined>(currentDefaultModel)
const [defaultModelSourceKey, setDefaultModelSourceKey] = useState(currentDefaultModelKey)
const selectedDefaultModel = defaultModelSourceKey === currentDefaultModelKey
? defaultModelState
: currentDefaultModel
const handleDefaultModelChange = useCallback((model: DefaultModel) => {
setDefaultModelSourceKey(currentDefaultModelKey)
setDefaultModelState(model)
}, [currentDefaultModelKey])
}, [])
useEffect(() => {
setDefaultModelState(currentDefaultModel)
}, [currentDefaultModel])
return [selectedDefaultModel, handleDefaultModelChange]
return [defaultModelState, handleDefaultModelChange]
}
export const useLanguage = () => {
@@ -122,7 +116,7 @@ export const useProviderCredentialsAndLoadBalancing = (
predefinedFormSchemasValue?.credentials,
])
const mutate = useCallback(() => {
const mutate = useMemo(() => () => {
if (predefinedEnabled)
queryClient.invalidateQueries({ queryKey: ['model-providers', 'credentials', provider, credentialId] })
if (customEnabled)
@@ -228,14 +222,6 @@ export const useUpdateModelList = () => {
return updateModelList
}
export const useInvalidateDefaultModel = () => {
const queryClient = useQueryClient()
return useCallback((type: ModelTypeEnum) => {
queryClient.invalidateQueries({ queryKey: commonQueryKeys.defaultModel(type) })
}, [queryClient])
}
export const useAnthropicBuyQuota = () => {
const [loading, setLoading] = useState(false)
@@ -328,8 +314,7 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText:
}
export const useRefreshModel = () => {
const expandModelProviderList = useExpandModelProviderList()
const queryClient = useQueryClient()
const { eventEmitter } = useEventEmitterContextContext()
const updateModelProviders = useUpdateModelProviders()
const updateModelList = useUpdateModelList()
const handleRefreshModel = useCallback((
@@ -337,19 +322,6 @@ export const useRefreshModel = () => {
CustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
refreshModelList?: boolean,
) => {
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: provider.provider,
},
},
})
queryClient.invalidateQueries({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'none',
})
updateModelProviders()
provider.supported_model_types.forEach((type) => {
@@ -357,17 +329,15 @@ export const useRefreshModel = () => {
})
if (refreshModelList && provider.custom_configuration.status === CustomConfigurationStatusEnum.active) {
expandModelProviderList(provider.provider)
queryClient.invalidateQueries({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'active',
})
eventEmitter?.emit({
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
payload: provider.provider,
} as any)
if (CustomConfigurationModelFixedFields?.__model_type)
updateModelList(CustomConfigurationModelFixedFields.__model_type)
}
}, [expandModelProviderList, queryClient, updateModelList, updateModelProviders])
}, [eventEmitter, updateModelList, updateModelProviders])
return {
handleRefreshModel,

View File

@@ -7,7 +7,16 @@ import {
} from './declarations'
import ModelProviderPage from './index'
let mockEnableMarketplace = true
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
mutateCurrentWorkspace: vi.fn(),
isValidatingCurrentWorkspace: false,
}),
}))
const mockGlobalState = {
systemFeatures: { enable_marketplace: true },
}
const mockQuotaConfig = {
quota_type: CurrentSystemQuotaTypeEnum.free,
@@ -19,11 +28,7 @@ const mockQuotaConfig = {
}
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({
data: {
enable_marketplace: mockEnableMarketplace,
},
}),
useGlobalPublicStore: (selector: (s: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector(mockGlobalState),
}))
const mockProviders = [
@@ -55,16 +60,13 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
const mockDefaultModels: Record<string, { data: unknown, isLoading: boolean }> = {
'llm': { data: null, isLoading: false },
'text-embedding': { data: null, isLoading: false },
'rerank': { data: null, isLoading: false },
'speech2text': { data: null, isLoading: false },
'tts': { data: null, isLoading: false },
const mockDefaultModelState = {
data: null,
isLoading: false,
}
vi.mock('./hooks', () => ({
useDefaultModel: (type: string) => mockDefaultModels[type] ?? { data: null, isLoading: false },
useDefaultModel: () => mockDefaultModelState,
}))
vi.mock('./install-from-marketplace', () => ({
@@ -83,18 +85,13 @@ vi.mock('./system-model-selector', () => ({
default: () => <div data-testid="system-model-selector" />,
}))
vi.mock('@/service/use-plugins', () => ({
useCheckInstalled: () => ({ data: undefined }),
}))
describe('ModelProviderPage', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockEnableMarketplace = true
Object.keys(mockDefaultModels).forEach((key) => {
mockDefaultModels[key] = { data: null, isLoading: false }
})
mockGlobalState.systemFeatures.enable_marketplace = true
mockDefaultModelState.data = null
mockDefaultModelState.isLoading = false
mockProviders.splice(0, mockProviders.length, {
provider: 'openai',
label: { en_US: 'OpenAI' },
@@ -152,76 +149,13 @@ describe('ModelProviderPage', () => {
})
it('should hide marketplace section when marketplace feature is disabled', () => {
mockEnableMarketplace = false
mockGlobalState.systemFeatures.enable_marketplace = false
render(<ModelProviderPage searchText="" />)
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
})
describe('system model config status', () => {
it('should not show top warning when no configured providers exist (empty state card handles it)', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'anthropic',
label: { en_US: 'Anthropic' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.emptyProviderTitle')).toBeInTheDocument()
})
it('should show none-configured warning when providers exist but no default models set', () => {
render(<ModelProviderPage searchText="" />)
expect(screen.getByText('common.modelProvider.noneConfigured')).toBeInTheDocument()
})
it('should show partially-configured warning when some default models are set', () => {
mockDefaultModels.llm = {
data: { model: 'gpt-4', model_type: 'llm', provider: { provider: 'openai', icon_small: { en_US: '' } } },
isLoading: false,
}
render(<ModelProviderPage searchText="" />)
expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument()
})
it('should not show warning when all default models are configured', () => {
const makeModel = (model: string, type: string) => ({
data: { model, model_type: type, provider: { provider: 'openai', icon_small: { en_US: '' } } },
isLoading: false,
})
mockDefaultModels.llm = makeModel('gpt-4', 'llm')
mockDefaultModels['text-embedding'] = makeModel('text-embedding-3', 'text-embedding')
mockDefaultModels.rerank = makeModel('rerank-v3', 'rerank')
mockDefaultModels.speech2text = makeModel('whisper-1', 'speech2text')
mockDefaultModels.tts = makeModel('tts-1', 'tts')
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.noProviderInstalled')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
it('should not show warning while loading', () => {
Object.keys(mockDefaultModels).forEach((key) => {
mockDefaultModels[key] = { data: null, isLoading: true }
})
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.noProviderInstalled')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
})
it('should prioritize fixed providers in visible order', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'zeta-provider',

View File

@@ -1,14 +1,17 @@
import type {
ModelProvider,
} from './declarations'
import type { PluginDetail } from '@/app/components/plugins/types'
import {
RiAlertFill,
RiBrainLine,
} from '@remixicon/react'
import { useDebounce } from 'ahooks'
import { useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { IS_CLOUD_EDITION } from '@/config'
import { useSystemFeaturesQuery } from '@/context/global-public-context'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { useCheckInstalled } from '@/service/use-plugins'
import { cn } from '@/utils/classnames'
import {
CustomConfigurationStatusEnum,
@@ -21,9 +24,6 @@ import InstallFromMarketplace from './install-from-marketplace'
import ProviderAddedCard from './provider-added-card'
import QuotaPanel from './provider-added-card/quota-panel'
import SystemModelSelector from './system-model-selector'
import { providerToPluginId } from './utils'
type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-configured' | 'fully-configured'
type Props = {
searchText: string
@@ -34,35 +34,20 @@ const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/an
const ModelProviderPage = ({ searchText }: Props) => {
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
const { t } = useTranslation()
const { mutateCurrentWorkspace, isValidatingCurrentWorkspace } = useAppContext()
const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textGeneration)
const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textEmbedding)
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } = useDefaultModel(ModelTypeEnum.speech2text)
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts)
const { modelProviders: providers } = useProviderContext()
const { data: systemFeatures } = useSystemFeaturesQuery()
const allPluginIds = useMemo(() => {
return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))]
}, [providers])
const { data: installedPlugins } = useCheckInstalled({
pluginIds: allPluginIds,
enabled: allPluginIds.length > 0,
})
const pluginDetailMap = useMemo(() => {
const map = new Map<string, PluginDetail>()
if (installedPlugins?.plugins) {
for (const plugin of installedPlugins.plugins)
map.set(plugin.plugin_id, plugin)
}
return map
}, [installedPlugins])
const enableMarketplace = systemFeatures?.enable_marketplace ?? false
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const isDefaultModelLoading = isTextGenerationDefaultModelLoading
|| isEmbeddingsDefaultModelLoading
|| isRerankDefaultModelLoading
|| isSpeech2textDefaultModelLoading
|| isTTSDefaultModelLoading
const defaultModelNotConfigured = !isDefaultModelLoading && !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel
const [configuredProviders, notConfiguredProviders] = useMemo(() => {
const configuredProviders: ModelProvider[] = []
const notConfiguredProviders: ModelProvider[] = []
@@ -94,26 +79,6 @@ const ModelProviderPage = ({ searchText }: Props) => {
return [configuredProviders, notConfiguredProviders]
}, [providers])
const systemModelConfigStatus: SystemModelConfigStatus = useMemo(() => {
const defaultModels = [textGenerationDefaultModel, embeddingsDefaultModel, rerankDefaultModel, speech2textDefaultModel, ttsDefaultModel]
const configuredCount = defaultModels.filter(Boolean).length
if (configuredCount === 0 && configuredProviders.length === 0)
return 'no-provider'
if (configuredCount === 0)
return 'none-configured'
if (configuredCount < defaultModels.length)
return 'partially-configured'
return 'fully-configured'
}, [configuredProviders, textGenerationDefaultModel, embeddingsDefaultModel, rerankDefaultModel, speech2textDefaultModel, ttsDefaultModel])
const warningTextKey
= systemModelConfigStatus === 'none-configured'
? 'modelProvider.noneConfigured'
: systemModelConfigStatus === 'partially-configured'
? 'modelProvider.notConfigured'
: null
const showWarning = !isDefaultModelLoading && !!warningTextKey
const [filteredConfiguredProviders, filteredNotConfiguredProviders] = useMemo(() => {
const filteredConfiguredProviders = configuredProviders.filter(
provider => provider.provider.toLowerCase().includes(debouncedSearchText.toLowerCase())
@@ -127,24 +92,28 @@ const ModelProviderPage = ({ searchText }: Props) => {
return [filteredConfiguredProviders, filteredNotConfiguredProviders]
}, [configuredProviders, debouncedSearchText, notConfiguredProviders])
useEffect(() => {
mutateCurrentWorkspace()
}, [mutateCurrentWorkspace])
return (
<div className="relative -mt-2 pt-1">
<div className={cn('mb-2 flex items-center')}>
<div className="grow text-text-primary system-md-semibold">{t('modelProvider.models', { ns: 'common' })}</div>
<div className={cn(
'relative flex shrink-0 items-center justify-end gap-2 rounded-lg border border-transparent p-px',
showWarning && 'border-components-panel-border bg-components-panel-bg-blur pl-2 shadow-xs',
defaultModelNotConfigured && 'border-components-panel-border bg-components-panel-bg-blur pl-2 shadow-xs',
)}
>
{showWarning && <div className="absolute bottom-0 left-0 right-0 top-0 opacity-40" style={{ background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)' }} />}
{showWarning && (
{defaultModelNotConfigured && <div className="absolute bottom-0 left-0 right-0 top-0 opacity-40" style={{ background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)' }} />}
{defaultModelNotConfigured && (
<div className="flex items-center gap-1 text-text-primary system-xs-medium">
<span className="i-ri-alert-fill h-4 w-4 text-text-warning-secondary" />
<span className="max-w-[460px] truncate" title={t(warningTextKey, { ns: 'common' })}>{t(warningTextKey, { ns: 'common' })}</span>
<RiAlertFill className="h-4 w-4 text-text-warning-secondary" />
<span className="max-w-[460px] truncate" title={t('modelProvider.notConfigured', { ns: 'common' })}>{t('modelProvider.notConfigured', { ns: 'common' })}</span>
</div>
)}
<SystemModelSelector
notConfigured={showWarning}
notConfigured={defaultModelNotConfigured}
textGenerationDefaultModel={textGenerationDefaultModel}
embeddingsDefaultModel={embeddingsDefaultModel}
rerankDefaultModel={rerankDefaultModel}
@@ -154,11 +123,11 @@ const ModelProviderPage = ({ searchText }: Props) => {
/>
</div>
</div>
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} />}
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} isLoading={isValidatingCurrentWorkspace} />}
{!filteredConfiguredProviders?.length && (
<div className="mb-2 rounded-[10px] bg-workflow-process-bg p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur">
<span className="i-ri-brain-line h-5 w-5 text-text-primary" />
<RiBrainLine className="h-5 w-5 text-text-primary" />
</div>
<div className="mt-2 text-text-secondary system-sm-medium">{t('modelProvider.emptyProviderTitle', { ns: 'common' })}</div>
<div className="mt-1 text-text-tertiary system-xs-regular">{t('modelProvider.emptyProviderTip', { ns: 'common' })}</div>
@@ -170,7 +139,6 @@ const ModelProviderPage = ({ searchText }: Props) => {
<ProviderAddedCard
key={provider.provider}
provider={provider}
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
/>
))}
</div>
@@ -184,14 +152,13 @@ const ModelProviderPage = ({ searchText }: Props) => {
notConfigured
key={provider.provider}
provider={provider}
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
/>
))}
</div>
</>
)}
{
enableMarketplace && (
enable_marketplace && (
<InstallFromMarketplace
providers={providers}
searchText={searchText}

View File

@@ -2,6 +2,12 @@ import type { Credential } from '../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import CredentialItem from './credential-item'
vi.mock('@remixicon/react', () => ({
RiCheckLine: () => <div data-testid="check-icon" />,
RiDeleteBinLine: () => <div data-testid="delete-icon" />,
RiEqualizer2Line: () => <div data-testid="edit-icon" />,
}))
vi.mock('@/app/components/header/indicator', () => ({
default: () => <div data-testid="indicator" />,
}))
@@ -55,12 +61,8 @@ describe('CredentialItem', () => {
render(<CredentialItem credential={credential} onEdit={onEdit} onDelete={onDelete} />)
const buttons = screen.getAllByRole('button')
const editButton = buttons.find(b => b.querySelector('.i-ri-equalizer-2-line'))!
const deleteButton = buttons.find(b => b.querySelector('.i-ri-delete-bin-line'))!
fireEvent.click(editButton)
fireEvent.click(deleteButton)
fireEvent.click(screen.getByTestId('edit-icon').closest('button') as HTMLButtonElement)
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
expect(onEdit).toHaveBeenCalledWith(credential)
expect(onDelete).toHaveBeenCalledWith(credential)
@@ -79,10 +81,7 @@ describe('CredentialItem', () => {
/>,
)
const deleteButton = screen.getAllByRole('button')
.find(b => b.querySelector('.i-ri-delete-bin-line'))!
fireEvent.click(deleteButton)
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
expect(onDelete).not.toHaveBeenCalled()
})

View File

@@ -1,4 +1,9 @@
import type { Credential } from '../../declarations'
import {
RiCheckLine,
RiDeleteBinLine,
RiEqualizer2Line,
} from '@remixicon/react'
import {
memo,
useMemo,
@@ -6,7 +11,7 @@ import {
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import Tooltip from '@/app/components/base/tooltip'
import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames'
@@ -51,7 +56,7 @@ const CredentialItem = ({
key={credential.credential_id}
className={cn(
'group flex h-8 items-center rounded-lg p-1 hover:bg-state-base-hover',
(disabled || credential.not_allowed_to_use) ? 'cursor-not-allowed opacity-50' : onItemClick && 'cursor-pointer',
(disabled || credential.not_allowed_to_use) && 'cursor-not-allowed opacity-50',
)}
onClick={() => {
if (disabled || credential.not_allowed_to_use)
@@ -65,7 +70,7 @@ const CredentialItem = ({
<div className="h-4 w-4">
{
selectedCredentialId === credential.credential_id && (
<span className="i-ri-check-line h-4 w-4 text-text-accent" />
<RiCheckLine className="h-4 w-4 text-text-accent" />
)
}
</div>
@@ -73,7 +78,7 @@ const CredentialItem = ({
}
<Indicator className="ml-2 mr-1.5 shrink-0" />
<div
className="truncate text-text-secondary system-md-regular"
className="system-md-regular truncate text-text-secondary"
title={credential.credential_name}
>
{credential.credential_name}
@@ -91,50 +96,38 @@ const CredentialItem = ({
<div className="ml-2 hidden shrink-0 items-center group-hover:flex">
{
!disableEdit && !credential.not_allowed_to_use && (
<Tooltip>
<TooltipTrigger
render={(
<ActionButton
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onEdit?.(credential)
}}
>
<span className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
</ActionButton>
)}
/>
<TooltipContent>{t('operation.edit', { ns: 'common' })}</TooltipContent>
<Tooltip popupContent={t('operation.edit', { ns: 'common' })}>
<ActionButton
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onEdit?.(credential)
}}
>
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</ActionButton>
</Tooltip>
)
}
{
!disableDelete && (
<Tooltip>
<TooltipTrigger
render={(
<ActionButton
className="hover:bg-transparent"
onClick={(e) => {
if (disabled || disableDeleteWhenSelected)
return
e.stopPropagation()
onDelete?.(credential)
}}
>
<span className={cn(
'i-ri-delete-bin-line h-4 w-4 text-text-tertiary',
!disableDeleteWhenSelected && 'hover:text-text-destructive',
disableDeleteWhenSelected && 'opacity-50',
)}
/>
</ActionButton>
<Tooltip popupContent={disableDeleteWhenSelected ? disableDeleteTip : t('operation.delete', { ns: 'common' })}>
<ActionButton
className="hover:bg-transparent"
onClick={(e) => {
if (disabled || disableDeleteWhenSelected)
return
e.stopPropagation()
onDelete?.(credential)
}}
>
<RiDeleteBinLine className={cn(
'h-4 w-4 text-text-tertiary',
!disableDeleteWhenSelected && 'hover:text-text-destructive',
disableDeleteWhenSelected && 'opacity-50',
)}
/>
<TooltipContent>
{disableDeleteWhenSelected ? disableDeleteTip : t('operation.delete', { ns: 'common' })}
</TooltipContent>
/>
</ActionButton>
</Tooltip>
)
}
@@ -146,9 +139,8 @@ const CredentialItem = ({
if (credential.not_allowed_to_use) {
return (
<Tooltip>
<TooltipTrigger render={Item} />
<TooltipContent>{t('auth.customCredentialUnavailable', { ns: 'plugin' })}</TooltipContent>
<Tooltip popupContent={t('auth.customCredentialUnavailable', { ns: 'plugin' })}>
{Item}
</Tooltip>
)
}

View File

@@ -10,7 +10,7 @@ const ModelBadge: FC<ModelBadgeProps> = ({
children,
}) => {
return (
<div className={cn('inline-flex h-[18px] shrink-0 items-center justify-center whitespace-nowrap rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] text-text-tertiary system-2xs-medium-uppercase', className)}>
<div className={cn('system-2xs-medium-uppercase flex h-[18px] cursor-default items-center rounded-[5px] border border-divider-deep px-1 text-text-tertiary', className)}>
{children}
</div>
)

View File

@@ -1,5 +1,5 @@
import type { Credential, CredentialFormSchema, ModelProvider } from '../declarations'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
@@ -243,10 +243,9 @@ describe('ModelModal', () => {
const credential: Credential = { credential_id: 'cred-1' }
const { onCancel } = renderModal({ credential })
const alertDialog = screen.getByRole('alertdialog', { hidden: true })
expect(alertDialog).toHaveTextContent('common.modelProvider.confirmDelete')
expect(screen.getByText('common.modelProvider.confirmDelete')).toBeInTheDocument()
fireEvent.click(within(alertDialog).getByRole('button', { hidden: true, name: 'common.operation.confirm' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(1)

View File

@@ -9,9 +9,11 @@ import type {
FormRefObject,
FormSchema,
} from '@/app/components/base/form/types'
import { RiCloseLine } from '@remixicon/react'
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
@@ -19,23 +21,15 @@ import {
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
import Loading from '@/app/components/base/loading'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import {
Dialog,
DialogCloseButton,
DialogContent,
} from '@/app/components/base/ui/dialog'
PortalToFollowElem,
PortalToFollowElemContent,
} from '@/app/components/base/portal-to-follow-elem'
import {
useAuth,
useCredentialData,
@@ -203,7 +197,7 @@ const ModelModal: FC<ModelModalProps> = ({
}
return (
<div className="text-text-primary title-2xl-semi-bold">
<div className="title-2xl-semi-bold text-text-primary">
{label}
</div>
)
@@ -212,7 +206,7 @@ const ModelModal: FC<ModelModalProps> = ({
const modalDesc = useMemo(() => {
if (providerFormSchemaPredefined) {
return (
<div className="mt-1 text-text-tertiary system-xs-regular">
<div className="system-xs-regular mt-1 text-text-tertiary">
{t('modelProvider.auth.apiKeyModal.desc', { ns: 'common' })}
</div>
)
@@ -229,7 +223,7 @@ const ModelModal: FC<ModelModalProps> = ({
className="mr-2 h-4 w-4 shrink-0"
provider={provider}
/>
<div className="mr-1 text-text-secondary system-md-regular">{renderI18nObject(provider.label)}</div>
<div className="system-md-regular mr-1 text-text-secondary">{renderI18nObject(provider.label)}</div>
</div>
)
}
@@ -241,7 +235,7 @@ const ModelModal: FC<ModelModalProps> = ({
provider={provider}
modelName={model.model}
/>
<div className="mr-1 text-text-secondary system-md-regular">{model.model}</div>
<div className="system-md-regular mr-1 text-text-secondary">{model.model}</div>
<Badge>{model.model_type}</Badge>
</div>
)
@@ -281,171 +275,174 @@ const ModelModal: FC<ModelModalProps> = ({
}, [])
const notAllowCustomCredential = provider.allow_custom_token === false
const handleOpenChange = useCallback((open: boolean) => {
if (!open)
onCancel()
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation()
onCancel()
}
}
document.addEventListener('keydown', handleKeyDown, true)
return () => {
document.removeEventListener('keydown', handleKeyDown, true)
}
}, [onCancel])
const handleConfirmOpenChange = useCallback((open: boolean) => {
if (!open)
closeConfirmDelete()
}, [closeConfirmDelete])
return (
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[640px] max-w-[640px] overflow-hidden p-0"
>
<DialogCloseButton className="right-5 top-5 h-8 w-8" />
<div className="p-6 pb-3">
{modalTitle}
{modalDesc}
{modalModel}
</div>
<div className="max-h-[calc(100vh-320px)] overflow-y-auto px-6 py-3">
{
mode === ModelModalModeEnum.configCustomModel && (
<AuthForm
formSchemas={modelNameAndTypeFormSchemas.map((formSchema) => {
return {
...formSchema,
name: formSchema.variable,
}
}) as FormSchema[]}
defaultValues={modelNameAndTypeFormValues}
inputClassName="justify-start"
ref={formRef1}
onChange={handleModelNameAndTypeChange}
/>
)
}
{
mode === ModelModalModeEnum.addCustomModelToModelList && (
<CredentialSelector
credentials={available_credentials || []}
onSelect={setSelectedCredential}
selectedCredential={selectedCredential}
disabled={isLoading}
notAllowAddNewCredential={notAllowCustomCredential}
/>
)
}
{
showCredentialLabel && (
<div className="mb-3 mt-6 flex items-center text-text-tertiary system-xs-medium-uppercase">
{t('modelProvider.auth.modelCredential', { ns: 'common' })}
<div className="ml-2 h-px grow bg-gradient-to-r from-divider-regular to-background-gradient-mask-transparent" />
</div>
)
}
{
isLoading && (
<div className="mt-3 flex items-center justify-center">
<Loading />
</div>
)
}
{
!isLoading
&& showCredentialForm
&& (
<AuthForm
formSchemas={formSchemas.map((formSchema) => {
return {
...formSchema,
name: formSchema.variable,
showRadioUI: formSchema.type === FormTypeEnum.radio,
}
}) as FormSchema[]}
defaultValues={formValues}
inputClassName="justify-start"
ref={formRef2}
/>
)
}
</div>
<div className="flex justify-between p-6 pt-5">
{
(provider.help && (provider.help.title || provider.help.url))
? (
<a
href={provider.help?.url[language] || provider.help?.url.en_US}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block align-middle text-text-accent system-xs-regular"
onClick={e => !provider.help.url && e.preventDefault()}
>
{provider.help.title?.[language] || provider.help.url[language] || provider.help.title?.en_US || provider.help.url.en_US}
<LinkExternal02 className="ml-1 mt-[-2px] inline-block h-3 w-3" />
</a>
)
: <div />
}
<div className="ml-2 flex items-center justify-end space-x-2">
{
isEditMode && (
<Button
variant="warning"
onClick={() => openConfirmDelete(credential, model)}
>
{t('operation.remove', { ns: 'common' })}
</Button>
)
}
<Button
<PortalToFollowElem open>
<PortalToFollowElemContent className="z-[60] h-full w-full">
<div className="fixed inset-0 flex items-center justify-center bg-black/[.25]">
<div className="relative w-[640px] rounded-2xl bg-components-panel-bg shadow-xl">
<div
className="absolute right-5 top-5 flex h-8 w-8 cursor-pointer items-center justify-center"
onClick={onCancel}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={isLoading || doingAction}
>
{saveButtonText}
</Button>
</div>
</div>
{
(mode === ModelModalModeEnum.configCustomModel || mode === ModelModalModeEnum.configProviderCredential) && (
<div className="border-t-[0.5px] border-t-divider-regular">
<div className="flex items-center justify-center rounded-b-2xl bg-background-section-burn py-3 text-xs text-text-tertiary">
<Lock01 className="mr-1 h-3 w-3 text-text-tertiary" />
{t('modelProvider.encrypted.front', { ns: 'common' })}
<a
className="mx-1 text-text-accent"
target="_blank"
rel="noopener noreferrer"
href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html"
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
<div className="p-6 pb-3">
{modalTitle}
{modalDesc}
{modalModel}
</div>
<div className="max-h-[calc(100vh-320px)] overflow-y-auto px-6 py-3">
{
mode === ModelModalModeEnum.configCustomModel && (
<AuthForm
formSchemas={modelNameAndTypeFormSchemas.map((formSchema) => {
return {
...formSchema,
name: formSchema.variable,
}
}) as FormSchema[]}
defaultValues={modelNameAndTypeFormValues}
inputClassName="justify-start"
ref={formRef1}
onChange={handleModelNameAndTypeChange}
/>
)
}
{
mode === ModelModalModeEnum.addCustomModelToModelList && (
<CredentialSelector
credentials={available_credentials || []}
onSelect={setSelectedCredential}
selectedCredential={selectedCredential}
disabled={isLoading}
notAllowAddNewCredential={notAllowCustomCredential}
/>
)
}
{
showCredentialLabel && (
<div className="system-xs-medium-uppercase mb-3 mt-6 flex items-center text-text-tertiary">
{t('modelProvider.auth.modelCredential', { ns: 'common' })}
<div className="ml-2 h-px grow bg-gradient-to-r from-divider-regular to-background-gradient-mask-transparent" />
</div>
)
}
{
isLoading && (
<div className="mt-3 flex items-center justify-center">
<Loading />
</div>
)
}
{
!isLoading
&& showCredentialForm
&& (
<AuthForm
formSchemas={formSchemas.map((formSchema) => {
return {
...formSchema,
name: formSchema.variable,
showRadioUI: formSchema.type === FormTypeEnum.radio,
}
}) as FormSchema[]}
defaultValues={formValues}
inputClassName="justify-start"
ref={formRef2}
/>
)
}
</div>
<div className="flex justify-between p-6 pt-5">
{
(provider.help && (provider.help.title || provider.help.url))
? (
<a
href={provider.help?.url[language] || provider.help?.url.en_US}
target="_blank"
rel="noopener noreferrer"
className="system-xs-regular mt-2 inline-block align-middle text-text-accent"
onClick={e => !provider.help.url && e.preventDefault()}
>
{provider.help.title?.[language] || provider.help.url[language] || provider.help.title?.en_US || provider.help.url.en_US}
<LinkExternal02 className="ml-1 mt-[-2px] inline-block h-3 w-3" />
</a>
)
: <div />
}
<div className="ml-2 flex items-center justify-end space-x-2">
{
isEditMode && (
<Button
variant="warning"
onClick={() => openConfirmDelete(credential, model)}
>
{t('operation.remove', { ns: 'common' })}
</Button>
)
}
<Button
onClick={onCancel}
>
PKCS1_OAEP
</a>
{t('modelProvider.encrypted.back', { ns: 'common' })}
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={isLoading || doingAction}
>
{saveButtonText}
</Button>
</div>
</div>
)
}
</DialogContent>
<AlertDialog open={!!deleteCredentialId} onOpenChange={handleConfirmOpenChange}>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<div className="flex flex-col gap-2 p-6 pb-4">
<AlertDialogTitle className="text-text-primary title-2xl-semi-bold">
{t('modelProvider.confirmDelete', { ns: 'common' })}
</AlertDialogTitle>
{
(mode === ModelModalModeEnum.configCustomModel || mode === ModelModalModeEnum.configProviderCredential) && (
<div className="border-t-[0.5px] border-t-divider-regular">
<div className="flex items-center justify-center rounded-b-2xl bg-background-section-burn py-3 text-xs text-text-tertiary">
<Lock01 className="mr-1 h-3 w-3 text-text-tertiary" />
{t('modelProvider.encrypted.front', { ns: 'common' })}
<a
className="mx-1 text-text-accent"
target="_blank"
rel="noopener noreferrer"
href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html"
>
PKCS1_OAEP
</a>
{t('modelProvider.encrypted.back', { ns: 'common' })}
</div>
</div>
)
}
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton
disabled={doingAction}
onClick={handleDeleteCredential}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</Dialog>
{
deleteCredentialId && (
<Confirm
isShow
title={t('modelProvider.confirmDelete', { ns: 'common' })}
isDisabled={doingAction}
onCancel={closeConfirmDelete}
onConfirm={handleDeleteCredential}
/>
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@@ -1,5 +1,7 @@
import type { FC } from 'react'
import { RiEqualizer2Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { CubeOutline } from '@/app/components/base/icons/src/vender/line/shapes'
import { cn } from '@/utils/classnames'
type ModelTriggerProps = {
@@ -14,26 +16,24 @@ const ModelTrigger: FC<ModelTriggerProps> = ({
return (
<div
className={cn(
'group flex h-8 cursor-pointer items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 hover:bg-components-input-bg-hover',
'flex cursor-pointer items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 hover:bg-components-input-bg-hover',
open && 'bg-components-input-bg-hover',
className,
)}
>
<div className="flex h-6 w-6 items-center justify-center">
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span className="i-ri-brain-2-line h-3.5 w-3.5 text-text-quaternary" />
<div className="flex grow items-center">
<div className="mr-1.5 flex h-4 w-4 items-center justify-center rounded-[5px] border border-dashed border-divider-regular">
<CubeOutline className="h-3 w-3 text-text-quaternary" />
</div>
</div>
<div className="flex grow items-center gap-1 truncate px-1 py-[3px]">
<div
className="grow truncate text-[13px] text-text-quaternary"
className="truncate text-[13px] text-text-tertiary"
title="Configure model"
>
{t('detailPanel.configureModel', { ns: 'plugin' })}
</div>
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary" />
</div>
</div>
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<RiEqualizer2Line className="h-3.5 w-3.5 text-text-tertiary" />
</div>
</div>
)

View File

@@ -5,7 +5,7 @@ import type {
ModelFeatureEnum,
ModelItem,
} from '../declarations'
import { useRef, useState } from 'react'
import { useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@@ -41,7 +41,6 @@ const ModelSelector: FC<ModelSelectorProps> = ({
showDeprecatedWarnIcon = false,
}) => {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLDivElement>(null)
const {
currentProvider,
currentModel,
@@ -71,7 +70,7 @@ const ModelSelector: FC<ModelSelectorProps> = ({
placement="bottom-start"
offset={4}
>
<div ref={triggerRef} className={cn('relative')}>
<div className={cn('relative')}>
<PortalToFollowElemTrigger
onClick={handleToggle}
className="block"
@@ -114,7 +113,6 @@ const ModelSelector: FC<ModelSelectorProps> = ({
onSelect={handleSelect}
scopeFeatures={scopeFeatures}
onHide={() => setOpen(false)}
triggerRef={triggerRef}
/>
</PortalToFollowElemContent>
</div>

View File

@@ -1,5 +1,5 @@
import type { Model, ModelItem } from '../declarations'
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
@@ -60,8 +60,8 @@ describe('ModelTrigger', () => {
expect(screen.getByText('GPT-4')).toBeInTheDocument()
})
it('should show status badge when model is not active', () => {
render(
it('should show status tooltip content when model is not active', async () => {
const { container } = render(
<ModelTrigger
open={false}
provider={makeModel()}
@@ -69,7 +69,10 @@ describe('ModelTrigger', () => {
/>,
)
expect(screen.getByText(/modelProvider\.selector\.configureRequired/)).toBeInTheDocument()
const tooltipTrigger = container.querySelector('[data-state]') as HTMLElement
fireEvent.mouseEnter(tooltipTrigger)
expect(await screen.findByText('No Configure')).toBeInTheDocument()
})
it('should not show status icon when readonly', () => {
@@ -83,6 +86,6 @@ describe('ModelTrigger', () => {
)
expect(screen.getByText('GPT-4')).toBeInTheDocument()
expect(screen.queryByText(/modelProvider\.selector\.configureRequired/)).not.toBeInTheDocument()
expect(screen.queryByText('No Configure')).not.toBeInTheDocument()
})
})

View File

@@ -3,23 +3,18 @@ import type {
Model,
ModelItem,
} from '../declarations'
import { useTranslation } from 'react-i18next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { RiArrowDownSLine } from '@remixicon/react'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import { cn } from '@/utils/classnames'
import {
MODEL_STATUS_TEXT,
ModelStatusEnum,
} from '../declarations'
import { useLanguage } from '../hooks'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
const STATUS_I18N_KEY: Partial<Record<ModelStatusEnum, string>> = {
[ModelStatusEnum.quotaExceeded]: 'modelProvider.selector.creditsExhausted',
[ModelStatusEnum.noConfigure]: 'modelProvider.selector.configureRequired',
[ModelStatusEnum.noPermission]: 'modelProvider.selector.incompatible',
[ModelStatusEnum.disabled]: 'modelProvider.selector.disabled',
[ModelStatusEnum.credentialRemoved]: 'modelProvider.selector.apiKeyUnavailable',
}
type ModelTriggerProps = {
open: boolean
provider: Model
@@ -34,9 +29,7 @@ const ModelTrigger: FC<ModelTriggerProps> = ({
className,
readonly,
}) => {
const { t } = useTranslation()
const isActive = model.status === ModelStatusEnum.active
const statusI18nKey = STATUS_I18N_KEY[model.status]
const language = useLanguage()
return (
<div
@@ -44,7 +37,7 @@ const ModelTrigger: FC<ModelTriggerProps> = ({
'group flex h-8 items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1',
!readonly && 'cursor-pointer hover:bg-components-input-bg-hover',
open && 'bg-components-input-bg-hover',
!isActive && 'bg-components-input-bg-disabled hover:bg-components-input-bg-disabled',
model.status !== ModelStatusEnum.active && 'bg-components-input-bg-disabled hover:bg-components-input-bg-disabled',
className,
)}
>
@@ -60,26 +53,22 @@ const ModelTrigger: FC<ModelTriggerProps> = ({
showMode
showFeatures
/>
{!readonly && !isActive && statusI18nKey && (
<Tooltip>
<TooltipTrigger
disabled={model.status !== ModelStatusEnum.noPermission}
render={(
<div className="flex shrink-0 items-center gap-[3px] rounded-md border border-text-warning px-[5px] py-0.5">
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
<span className="whitespace-nowrap text-text-warning system-xs-medium">
{t(statusI18nKey as 'modelProvider.selector.creditsExhausted', { ns: 'common' })}
</span>
</div>
)}
/>
<TooltipContent placement="top" className="z-[1003]">
{t('modelProvider.selector.incompatibleTip', { ns: 'common' })}
</TooltipContent>
</Tooltip>
)}
{!readonly && isActive && (
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5 shrink-0 text-text-tertiary" />
{!readonly && (
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
{
model.status !== ModelStatusEnum.active
? (
<Tooltip popupContent={MODEL_STATUS_TEXT[model.status][language]}>
<AlertTriangle className="h-4 w-4 text-text-warning-secondary" />
</Tooltip>
)
: (
<RiArrowDownSLine
className="h-3.5 w-3.5 text-text-tertiary"
/>
)
}
</div>
)}
</div>
</div>

View File

@@ -2,11 +2,9 @@ import type { DefaultModel, Model, ModelItem } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
ModelFeatureEnum,
ModelStatusEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '../declarations'
import PopupItem from './popup-item'
@@ -35,14 +33,6 @@ vi.mock('../model-name', () => ({
default: ({ modelItem }: { modelItem: ModelItem }) => <span>{modelItem.label.en_US}</span>,
}))
vi.mock('./feature-icon', () => ({
default: ({ feature }: { feature: string }) => <span data-testid="feature-icon">{feature}</span>,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
const mockSetShowModelModal = vi.hoisted(() => vi.fn())
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
@@ -55,11 +45,6 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: mockUseProviderContext,
}))
const mockUseAppContext = vi.hoisted(() => vi.fn())
vi.mock('@/context/app-context', () => ({
useAppContext: mockUseAppContext,
}))
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
@@ -81,24 +66,11 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
...overrides,
})
const makeProvider = (overrides: Record<string, unknown> = {}) => ({
provider: 'openai',
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_name: 'my-api-key',
},
...overrides,
})
describe('PopupItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider()],
})
mockUseAppContext.mockReturnValue({
currentWorkspace: { trial_credits: 200, trial_credits_used: 0 },
modelProviders: [{ provider: 'openai' }],
})
})
@@ -172,66 +144,4 @@ describe('PopupItem', () => {
expect(screen.getByText('GPT-4')).toBeInTheDocument()
})
it('should toggle collapsed state when clicking provider header', () => {
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
expect(screen.getByText('GPT-4')).toBeInTheDocument()
fireEvent.click(screen.getByText('OpenAI'))
expect(screen.queryByText('GPT-4')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('OpenAI'))
expect(screen.getByText('GPT-4')).toBeInTheDocument()
})
it('should show credential name when using custom provider', () => {
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
expect(screen.getByText('my-api-key')).toBeInTheDocument()
})
it('should show configure required when no credential name', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
current_credential_name: '',
},
})],
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.configureRequired/)).toBeInTheDocument()
})
it('should show credits info when using system provider with remaining credits', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider({
preferred_provider_type: PreferredProviderTypeEnum.system,
})],
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.aiCredits/)).toBeInTheDocument()
})
it('should show credits exhausted when system provider has no credits', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider({
preferred_provider_type: PreferredProviderTypeEnum.system,
})],
})
mockUseAppContext.mockReturnValue({
currentWorkspace: { trial_credits: 100, trial_credits_used: 100 },
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/)).toBeInTheDocument()
})
})

View File

@@ -4,21 +4,18 @@ import type {
Model,
ModelItem,
} from '../declarations'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
import Tooltip from '@/app/components/base/tooltip'
import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
ModelFeatureEnum,
ModelStatusEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '../declarations'
import {
useLanguage,
@@ -44,10 +41,8 @@ const PopupItem: FC<PopupItemProps> = ({
model,
onSelect,
}) => {
const [collapsed, setCollapsed] = useState(false)
const { t } = useTranslation()
const language = useLanguage()
const { currentWorkspace } = useAppContext()
const { setShowModelModal } = useModalContext()
const { modelProviders } = useProviderContext()
const updateModelList = useUpdateModelList()
@@ -76,144 +71,101 @@ const PopupItem: FC<PopupItemProps> = ({
})
}
const isUsingCredits = currentProvider?.preferred_provider_type === PreferredProviderTypeEnum.system
const credits = Math.max((currentWorkspace.trial_credits - currentWorkspace.trial_credits_used) || 0, 0)
const hasCredits = credits > 0
const isApiKeyActive = currentProvider?.custom_configuration.status === CustomConfigurationStatusEnum.active
const credentialName = currentProvider?.custom_configuration.current_credential_name
return (
<div className="mb-1">
<div className="flex h-[22px] items-center justify-between px-3 text-xs font-medium text-text-tertiary">
<div
className="flex cursor-pointer items-center"
onClick={() => setCollapsed(prev => !prev)}
>
{model.label[language] || model.label.en_US}
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', collapsed && '-rotate-90')} />
</div>
<div className="flex items-center text-text-tertiary system-xs-medium">
{isUsingCredits
? (
hasCredits
? (
<>
<span className="i-ri-globe-line h-3 w-3" />
<span className="ml-1">{t('modelProvider.selector.aiCredits', { ns: 'common' })}</span>
</>
)
: (
<>
<span className="i-ri-alert-fill h-3 w-3 text-text-warning-secondary" />
<span className="ml-1 text-text-warning">{t('modelProvider.selector.creditsExhausted', { ns: 'common' })}</span>
</>
)
)
: credentialName
? (
<>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-[2px] border', isApiKeyActive ? 'border-components-badge-status-light-success-border-inner bg-components-badge-status-light-success-bg' : 'border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg')} />
<span className="ml-1 text-text-tertiary">{credentialName}</span>
</>
)
: (
<>
<span className="h-1.5 w-1.5 shrink-0 rounded-[2px] border border-components-badge-status-light-disabled-border-inner bg-components-badge-status-light-disabled-bg" />
<span className="ml-1 text-text-tertiary">{t('modelProvider.selector.configureRequired', { ns: 'common' })}</span>
</>
)}
<span className={cn('i-ri-arrow-down-s-line !h-[14px] !w-[14px] translate-y-px text-text-tertiary', collapsed && '-rotate-90')} />
</div>
<div className="flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary">
{model.label[language] || model.label.en_US}
</div>
{!collapsed && model.models.map(modelItem => (
<Tooltip
key={modelItem.model}
position="right"
popupClassName="p-3 !w-[206px] bg-components-panel-bg-blur backdrop-blur-sm border-[0.5px] border-components-panel-border rounded-xl"
popupContent={(
<div className="flex flex-col gap-1">
<div className="flex flex-col items-start gap-2">
{
model.models.map(modelItem => (
<Tooltip
key={modelItem.model}
position="right"
popupClassName="p-3 !w-[206px] bg-components-panel-bg-blur backdrop-blur-sm border-[0.5px] border-components-panel-border rounded-xl"
popupContent={(
<div className="flex flex-col gap-1">
<div className="flex flex-col items-start gap-2">
<ModelIcon
className={cn('h-5 w-5 shrink-0')}
provider={model}
modelName={modelItem.model}
/>
<div className="system-md-medium text-wrap break-words text-text-primary">{modelItem.label[language] || modelItem.label.en_US}</div>
</div>
{/* {currentProvider?.description && (
<div className='text-text-tertiary system-xs-regular'>{currentProvider?.description?.[language] || currentProvider?.description?.en_US}</div>
)} */}
<div className="flex flex-wrap gap-1">
{!!modelItem.model_type && (
<ModelBadge>
{modelTypeFormat(modelItem.model_type)}
</ModelBadge>
)}
{!!modelItem.model_properties.mode && (
<ModelBadge>
{(modelItem.model_properties.mode as string).toLocaleUpperCase()}
</ModelBadge>
)}
{!!modelItem.model_properties.context_size && (
<ModelBadge>
{sizeFormat(modelItem.model_properties.context_size as number)}
</ModelBadge>
)}
</div>
{[ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank].includes(modelItem.model_type as ModelTypeEnum)
&& modelItem.features?.some(feature => [ModelFeatureEnum.vision, ModelFeatureEnum.audio, ModelFeatureEnum.video, ModelFeatureEnum.document].includes(feature))
&& (
<div className="pt-2">
<div className="system-2xs-medium-uppercase mb-1 text-text-tertiary">{t('model.capabilities', { ns: 'common' })}</div>
<div className="flex flex-wrap gap-1">
{modelItem.features?.map(feature => (
<FeatureIcon
key={feature}
feature={feature}
showFeaturesLabel
/>
))}
</div>
</div>
)}
</div>
)}
>
<div
key={modelItem.model}
className={cn('group relative flex h-8 items-center gap-1 rounded-lg px-3 py-1.5', modelItem.status === ModelStatusEnum.active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed hover:bg-state-base-hover-alt')}
onClick={() => handleSelect(model.provider, modelItem)}
>
<div className="flex items-center gap-2">
<ModelIcon
className={cn('h-5 w-5 shrink-0')}
provider={model}
modelName={modelItem.model}
/>
<div className="text-wrap break-words text-text-primary system-md-medium">{modelItem.label[language] || modelItem.label.en_US}</div>
<ModelName
className={cn('system-sm-medium text-text-secondary', modelItem.status !== ModelStatusEnum.active && 'opacity-60')}
modelItem={modelItem}
/>
</div>
{/* {currentProvider?.description && (
<div className='text-text-tertiary system-xs-regular'>{currentProvider?.description?.[language] || currentProvider?.description?.en_US}</div>
)} */}
<div className="flex flex-wrap gap-1">
{!!modelItem.model_type && (
<ModelBadge>
{modelTypeFormat(modelItem.model_type)}
</ModelBadge>
)}
{!!modelItem.model_properties.mode && (
<ModelBadge>
{(modelItem.model_properties.mode as string).toLocaleUpperCase()}
</ModelBadge>
)}
{!!modelItem.model_properties.context_size && (
<ModelBadge>
{sizeFormat(modelItem.model_properties.context_size as number)}
</ModelBadge>
)}
</div>
{[ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank].includes(modelItem.model_type as ModelTypeEnum)
&& modelItem.features?.some(feature => [ModelFeatureEnum.vision, ModelFeatureEnum.audio, ModelFeatureEnum.video, ModelFeatureEnum.document].includes(feature))
&& (
<div className="pt-2">
<div className="mb-1 text-text-tertiary system-2xs-medium-uppercase">{t('model.capabilities', { ns: 'common' })}</div>
<div className="flex flex-wrap gap-1">
{modelItem.features?.map(feature => (
<FeatureIcon
key={feature}
feature={feature}
showFeaturesLabel
/>
))}
</div>
{
defaultModel?.model === modelItem.model && defaultModel.provider === currentProvider.provider && (
<Check className="h-4 w-4 shrink-0 text-text-accent" />
)
}
{
modelItem.status === ModelStatusEnum.noConfigure && (
<div
className="hidden cursor-pointer text-xs font-medium text-text-accent group-hover:block"
onClick={handleOpenModelModal}
>
{t('operation.add', { ns: 'common' }).toLocaleUpperCase()}
</div>
)}
)
}
</div>
)}
>
<div
key={modelItem.model}
className={cn('group relative flex h-8 items-center gap-1 rounded-lg px-3 py-1.5', modelItem.status === ModelStatusEnum.active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed hover:bg-state-base-hover-alt')}
onClick={() => handleSelect(model.provider, modelItem)}
>
<div className="flex items-center gap-2">
<ModelIcon
className={cn('h-5 w-5 shrink-0')}
provider={model}
modelName={modelItem.model}
/>
<ModelName
className={cn('text-text-secondary system-sm-medium', modelItem.status !== ModelStatusEnum.active && 'opacity-60')}
modelItem={modelItem}
/>
</div>
{
defaultModel?.model === modelItem.model && defaultModel.provider === currentProvider.provider && (
<span className="i-custom-vender-line-general-check h-4 w-4 shrink-0 text-text-accent" />
)
}
{
modelItem.status === ModelStatusEnum.noConfigure && (
<div
className="hidden cursor-pointer text-xs font-medium text-text-accent group-hover:block"
onClick={handleOpenModelModal}
>
{t('operation.add', { ns: 'common' }).toLocaleUpperCase()}
</div>
)
}
</div>
</Tooltip>
))}
</Tooltip>
))
}
</div>
)
}

View File

@@ -1,5 +1,5 @@
import type { Model, ModelItem } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
ModelFeatureEnum,
@@ -31,13 +31,17 @@ vi.mock('@/app/components/base/tooltip/TooltipManager', () => ({
},
}))
const mockMarketplacePlugins = vi.hoisted(() => ({ current: [] as Array<{ plugin_id: string, latest_package_identifier: string }> }))
vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({
XCircle: ({ onClick }: { onClick?: () => void }) => (
<button type="button" aria-label="clear-search" onClick={onClick} />
),
}))
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
...actual,
useLanguage: () => mockLanguage,
useMarketplaceAllPlugins: () => ({ plugins: mockMarketplacePlugins.current }),
}
})
@@ -45,53 +49,6 @@ vi.mock('./popup-item', () => ({
default: ({ model }: { model: Model }) => <div>{model.provider}</div>,
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ modelProviders: [] }),
}))
vi.mock('next-themes', () => ({
useTheme: () => ({ theme: 'light' }),
}))
const mockInstallMutateAsync = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-plugins', () => ({
useInstallPackageFromMarketPlace: () => ({ mutateAsync: mockInstallMutateAsync }),
}))
const mockRefreshPluginList = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
default: () => ({ refreshPluginList: mockRefreshPluginList }),
}))
const mockCheck = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
default: () => ({ check: mockCheck }),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: vi.fn(() => 'https://marketplace.example.com'),
}))
vi.mock('../utils', async () => {
const actual = await vi.importActual<typeof import('../utils')>('../utils')
return {
...actual,
MODEL_PROVIDER_QUOTA_GET_PAID: ['test-openai', 'test-anthropic'],
providerIconMap: {
'test-openai': ({ className }: { className?: string }) => <span className={className}>OAI</span>,
'test-anthropic': ({ className }: { className?: string }) => <span className={className}>ANT</span>,
},
modelNameMap: {
'test-openai': 'TestOpenAI',
'test-anthropic': 'TestAnthropic',
},
providerKeyToPluginId: {
'test-openai': 'langgenius/openai',
'test-anthropic': 'langgenius/anthropic',
},
}
})
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
@@ -117,11 +74,10 @@ describe('Popup', () => {
vi.clearAllMocks()
mockLanguage = 'en_US'
mockSupportFunctionCall.mockReturnValue(true)
mockMarketplacePlugins.current = []
})
it('should filter models by search and allow clearing search', () => {
const { container } = render(
render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
@@ -133,11 +89,9 @@ describe('Popup', () => {
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
fireEvent.change(input, { target: { value: 'not-found' } })
expect(screen.getByText('No model found for \u201Cnot-found\u201D')).toBeInTheDocument()
expect(screen.getByText('No model found for not-found')).toBeInTheDocument()
const clearIcon = container.querySelector('.i-custom-vender-solid-general-x-circle')
expect(clearIcon).toBeInTheDocument()
fireEvent.click(clearIcon!)
fireEvent.click(screen.getByRole('button', { name: 'clear-search' }))
expect((input as HTMLInputElement).value).toBe('')
})
@@ -146,6 +100,7 @@ describe('Popup', () => {
makeModel({ models: [makeModelItem({ features: [ModelFeatureEnum.toolCall, ModelFeatureEnum.vision] })] }),
]
// When tool-call support is missing, it should be filtered out.
mockSupportFunctionCall.mockReturnValue(false)
const { unmount } = render(
<Popup
@@ -155,8 +110,9 @@ describe('Popup', () => {
scopeFeatures={[ModelFeatureEnum.toolCall, ModelFeatureEnum.vision]}
/>,
)
expect(screen.getByText('No model found for \u201C\u201D')).toBeInTheDocument()
expect(screen.getByText('No model found for “”')).toBeInTheDocument()
// When tool-call support exists, the non-toolCall feature check should also pass.
unmount()
mockSupportFunctionCall.mockReturnValue(true)
const { unmount: unmount2 } = render(
@@ -180,6 +136,7 @@ describe('Popup', () => {
)
expect(screen.getByText('openai')).toBeInTheDocument()
// When features are missing, non-toolCall feature checks should fail.
unmount3()
render(
<Popup
@@ -189,7 +146,7 @@ describe('Popup', () => {
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
)
expect(screen.getByText('No model found for \u201C\u201D')).toBeInTheDocument()
expect(screen.getByText('No model found for “”')).toBeInTheDocument()
})
it('should match labels from other languages when current language key is missing', () => {
@@ -225,150 +182,18 @@ describe('Popup', () => {
})
it('should open provider settings when clicking footer link', () => {
const onHide = vi.fn()
render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={onHide}
onHide={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('common.modelProvider.selector.modelProviderSettings'))
fireEvent.click(screen.getByText('common.model.settingsLink'))
expect(onHide).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
})
})
it('should show empty state when no providers are configured', () => {
const onHide = vi.fn()
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={onHide}
/>,
)
expect(screen.getByText(/modelProvider\.selector\.noProviderConfigured(?!Desc)/)).toBeInTheDocument()
expect(screen.getByText(/modelProvider\.selector\.noProviderConfiguredDesc/)).toBeInTheDocument()
fireEvent.click(screen.getByText(/modelProvider\.selector\.configure/))
expect(onHide).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
})
})
it('should render marketplace providers that are not installed', () => {
render(
<Popup
modelList={[makeModel({ provider: 'test-openai' })]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.queryByText('TestOpenAI')).not.toBeInTheDocument()
expect(screen.getByText('TestAnthropic')).toBeInTheDocument()
expect(screen.getByText(/modelProvider\.selector\.fromMarketplace/)).toBeInTheDocument()
expect(screen.getByText(/modelProvider\.selector\.discoverMoreInMarketplace/)).toBeInTheDocument()
})
it('should toggle marketplace section collapse', () => {
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
fireEvent.click(screen.getByText(/modelProvider\.selector\.fromMarketplace/))
expect(screen.queryByText('TestOpenAI')).not.toBeInTheDocument()
fireEvent.click(screen.getByText(/modelProvider\.selector\.fromMarketplace/))
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
})
it('should install plugin when clicking install button', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
fireEvent.click(installButtons[0])
await waitFor(() => {
expect(mockInstallMutateAsync).toHaveBeenCalledWith('langgenius/openai:1.0.0')
})
expect(mockRefreshPluginList).toHaveBeenCalled()
})
it('should handle install failure gracefully', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockRejectedValue(new Error('Install failed'))
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
fireEvent.click(installButtons[0])
await waitFor(() => {
expect(mockInstallMutateAsync).toHaveBeenCalled()
})
// Should not crash, install buttons should still be available
expect(screen.getAllByText(/common\.modelProvider\.selector\.install/).length).toBeGreaterThan(0)
})
it('should run checkTaskStatus when not all_installed', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
mockCheck.mockResolvedValue(undefined)
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
fireEvent.click(installButtons[0])
await waitFor(() => {
expect(mockCheck).toHaveBeenCalledWith({
taskId: 'task-1',
pluginUniqueIdentifier: 'langgenius/openai:1.0.0',
})
})
expect(mockRefreshPluginList).toHaveBeenCalled()
})
})

View File

@@ -1,27 +1,22 @@
import type { FC, RefObject } from 'react'
import type { FC } from 'react'
import type {
DefaultModel,
Model,
ModelItem,
} from '../declarations'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { useTheme } from 'next-themes'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
RiArrowRightUpLine,
RiSearchLine,
} from '@remixicon/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { tooltipManager } from '@/app/components/base/tooltip/TooltipManager'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import { cn } from '@/utils/classnames'
import { supportFunctionCall } from '@/utils/tool-call'
import { getMarketplaceUrl } from '@/utils/var'
import { ModelFeatureEnum } from '../declarations'
import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils'
import { useLanguage } from '../hooks'
import PopupItem from './popup-item'
type PopupProps = {
@@ -30,7 +25,6 @@ type PopupProps = {
onSelect: (provider: string, model: ModelItem) => void
scopeFeatures?: ModelFeatureEnum[]
onHide: () => void
triggerRef?: RefObject<HTMLDivElement | null>
}
const Popup: FC<PopupProps> = ({
defaultModel,
@@ -38,48 +32,12 @@ const Popup: FC<PopupProps> = ({
onSelect,
scopeFeatures = [],
onHide,
triggerRef,
}) => {
const { t } = useTranslation()
const { theme } = useTheme()
const language = useLanguage()
const [searchText, setSearchText] = useState('')
const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false)
const { setShowAccountSettingModal } = useModalContext()
const { modelProviders } = useProviderContext()
const scrollRef = useRef<HTMLDivElement>(null)
const triggerWidth = triggerRef?.current?.offsetWidth
const {
plugins: allPlugins,
} = useMarketplaceAllPlugins(modelProviders, '')
const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace()
const { refreshPluginList } = useRefreshPluginList()
const [installingProvider, setInstallingProvider] = useState<ModelProviderQuotaGetPaid | null>(null)
const handleInstallPlugin = useCallback(async (key: ModelProviderQuotaGetPaid) => {
if (!allPlugins || installingProvider)
return
const pluginId = providerKeyToPluginId[key]
const plugin = allPlugins.find(p => p.plugin_id === pluginId)
if (!plugin)
return
const uniqueIdentifier = plugin.latest_package_identifier
setInstallingProvider(key)
try {
const { all_installed, task_id } = await installPackageFromMarketPlace(uniqueIdentifier)
if (!all_installed) {
const { check } = checkTaskStatus()
await check({ taskId: task_id, pluginUniqueIdentifier: uniqueIdentifier })
}
refreshPluginList(plugin)
}
catch { }
finally {
setInstallingProvider(null)
}
}, [allPlugins, installingProvider, installPackageFromMarketPlace, refreshPluginList])
// Close any open tooltips when the user scrolls to prevent them from appearing
// in incorrect positions or becoming detached from their trigger elements
@@ -123,22 +81,17 @@ const Popup: FC<PopupProps> = ({
}).filter(model => model.models.length > 0)
}, [language, modelList, scopeFeatures, searchText])
const marketplaceProviders = useMemo(() => {
const installedProviders = new Set(modelList.map(m => m.provider))
return MODEL_PROVIDER_QUOTA_GET_PAID.filter(key => !installedProviders.has(key))
}, [modelList])
return (
<div ref={scrollRef} className="max-h-[480px] min-w-[320px] overflow-y-auto rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg no-scrollbar" style={triggerWidth ? { width: triggerWidth } : undefined}>
<div ref={scrollRef} className="max-h-[480px] w-[320px] overflow-y-auto rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="sticky top-0 z-10 bg-components-panel-bg pb-1 pl-3 pr-2 pt-3">
<div className={`
flex h-8 items-center rounded-lg border pl-[9px] pr-[10px]
${searchText ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-transparent bg-components-input-bg-normal'}
`}
>
<span
<RiSearchLine
className={`
i-ri-search-line mr-[7px] h-[14px] w-[14px] shrink-0
mr-[7px] h-[14px] w-[14px] shrink-0
${searchText ? 'text-text-tertiary' : 'text-text-quaternary'}
`}
/>
@@ -150,8 +103,8 @@ const Popup: FC<PopupProps> = ({
/>
{
searchText && (
<span
className="i-custom-vender-solid-general-x-circle ml-1.5 h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
<XCircle
className="ml-1.5 h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
onClick={() => setSearchText('')}
/>
)
@@ -169,108 +122,23 @@ const Popup: FC<PopupProps> = ({
/>
))
}
{!filteredModelList.length && !modelList.length && (
<div className="flex flex-col gap-2 rounded-[10px] bg-gradient-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
{
!filteredModelList.length && (
<div className="break-all px-3 py-1.5 text-center text-xs leading-[18px] text-text-tertiary">
{`No model found for “${searchText}`}
</div>
<div className="flex flex-col gap-1">
<p className="text-text-secondary system-sm-medium">
{t('modelProvider.selector.noProviderConfigured', { ns: 'common' })}
</p>
<p className="text-text-tertiary system-xs-regular">
{t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })}
</p>
</div>
<Button
variant="primary"
className="w-[108px]"
onClick={() => {
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}}
>
{t('modelProvider.selector.configure', { ns: 'common' })}
<span className="i-ri-arrow-right-line h-4 w-4" />
</Button>
</div>
)}
{!filteredModelList.length && modelList.length > 0 && (
<div className="break-all px-3 py-1.5 text-center text-xs leading-[18px] text-text-tertiary">
{`No model found for \u201C${searchText}\u201D`}
</div>
)}
{marketplaceProviders.length > 0 && (
<>
<div className="mx-2 my-1 border-t border-divider-subtle" />
<div className="mb-1">
<div className="flex h-[22px] items-center px-3">
<div
className="flex flex-1 cursor-pointer items-center text-text-primary system-sm-medium"
onClick={() => setMarketplaceCollapsed(prev => !prev)}
>
{t('modelProvider.selector.fromMarketplace', { ns: 'common' })}
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', marketplaceCollapsed && '-rotate-90')} />
</div>
</div>
{!marketplaceCollapsed && (
<>
{marketplaceProviders.map((key) => {
const Icon = providerIconMap[key]
const isInstalling = installingProvider === key
return (
<div
key={key}
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pl-3 pr-0.5 hover:bg-state-base-hover"
>
<div className="flex flex-1 items-center gap-2 py-0.5">
<Icon className="h-5 w-5 shrink-0 rounded-md" />
<span className="text-text-secondary system-sm-regular">{modelNameMap[key]}</span>
</div>
<Button
variant="secondary"
size="small"
className={cn(
'shrink-0 backdrop-blur-[5px]',
!isInstalling && 'hidden group-hover:flex',
)}
disabled={isInstalling}
onClick={() => handleInstallPlugin(key)}
>
{isInstalling && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin" />}
{isInstalling
? t('installModal.installing', { ns: 'plugin' })
: t('modelProvider.selector.install', { ns: 'common' })}
</Button>
</div>
)
})}
<a
className="flex cursor-pointer items-center gap-0.5 px-3 pt-1.5"
href={getMarketplaceUrl('', { theme })}
target="_blank"
rel="noopener noreferrer"
>
<span className="flex-1 text-text-accent system-xs-regular">
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
</span>
<span className="i-ri-arrow-right-up-line !h-3 !w-3 text-text-accent" />
</a>
</>
)}
</div>
</>
)}
)
}
</div>
<div
className="sticky bottom-0 flex cursor-pointer items-center gap-1 rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-3 py-2 text-text-tertiary hover:text-text-secondary"
className="sticky bottom-0 flex cursor-pointer items-center rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-4 py-2 text-text-accent-light-mode-only"
onClick={() => {
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}}
>
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
<span className="system-xs-medium">{t('model.settingsLink', { ns: 'common' })}</span>
<RiArrowRightUpLine className="ml-0.5 h-3 w-3" />
</div>
</div>
)

View File

@@ -0,0 +1,17 @@
import { fireEvent, render, screen } from '@testing-library/react'
import AddModelButton from './add-model-button'
describe('AddModelButton', () => {
it('should render button with text', () => {
render(<AddModelButton onClick={vi.fn()} />)
expect(screen.getByText('common.modelProvider.addModel')).toBeInTheDocument()
})
it('should call onClick when clicked', () => {
const handleClick = vi.fn()
render(<AddModelButton onClick={handleClick} />)
const button = screen.getByText('common.modelProvider.addModel')
fireEvent.click(button)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,27 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { PlusCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { cn } from '@/utils/classnames'
type AddModelButtonProps = {
className?: string
onClick: () => void
}
const AddModelButton: FC<AddModelButtonProps> = ({
className,
onClick,
}) => {
const { t } = useTranslation()
return (
<span
className={cn('system-xs-medium flex h-6 shrink-0 cursor-pointer items-center rounded-md px-1.5 text-text-tertiary hover:bg-components-button-ghost-bg-hover hover:text-components-button-ghost-text', className)}
onClick={onClick}
>
<PlusCircle className="mr-1 h-3 w-3" />
{t('modelProvider.addModel', { ns: 'common' })}
</span>
)
}
export default AddModelButton

View File

@@ -1,54 +1,51 @@
import type { ModelProvider } from '../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
} from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { changeModelProviderPriority } from '@/service/common'
import { ConfigurationMethodEnum } from '../declarations'
import CredentialPanel from './credential-panel'
const {
mockToastNotify,
mockUpdateModelList,
mockUpdateModelProviders,
mockTrialCredits,
mockChangePriorityFn,
} = vi.hoisted(() => ({
mockToastNotify: vi.fn(),
mockUpdateModelList: vi.fn(),
mockUpdateModelProviders: vi.fn(),
mockTrialCredits: { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false, nextCreditResetDate: undefined },
mockChangePriorityFn: vi.fn().mockResolvedValue({ result: 'success' }),
}))
const mockEventEmitter = { emit: vi.fn() }
const mockNotify = vi.fn()
const mockUpdateModelList = vi.fn()
const mockUpdateModelProviders = vi.fn()
const mockCredentialStatus = {
hasCredential: true,
authorized: true,
authRemoved: false,
current_credential_name: 'test-credential',
notAllowedToUse: false,
}
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return { ...actual, IS_CLOUD_EDITION: true }
return {
...actual,
IS_CLOUD_EDITION: true,
}
})
vi.mock('@/app/components/base/toast', () => ({
default: { notify: mockToastNotify },
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
modelProviders: {
models: {
queryKey: ({ input }: { input: { params: { provider: string } } }) => ['console', 'modelProviders', 'models', input.params.provider],
},
changePreferredProviderType: {
mutationOptions: (opts: Record<string, unknown>) => ({
mutationFn: (...args: unknown[]) => {
mockChangePriorityFn(...args)
return Promise.resolve({ result: 'success' })
},
...opts,
}),
},
},
},
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: mockEventEmitter,
}),
}))
vi.mock('@/service/common', () => ({
changeModelProviderPriority: vi.fn(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
ConfigProvider: () => <div data-testid="config-provider" />,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth/hooks', () => ({
useCredentialStatus: () => mockCredentialStatus,
}))
vi.mock('../hooks', () => ({
@@ -56,375 +53,93 @@ vi.mock('../hooks', () => ({
useUpdateModelProviders: () => mockUpdateModelProviders,
}))
vi.mock('./use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
}))
vi.mock('./model-auth-dropdown', () => ({
default: ({ state, onChangePriority }: { state: { variant: string, hasCredentials: boolean }, onChangePriority: (key: string) => void }) => (
<div data-testid="model-auth-dropdown" data-variant={state.variant}>
<button data-testid="change-priority-btn" onClick={() => onChangePriority('custom')}>
Change Priority
</button>
</div>
vi.mock('./priority-selector', () => ({
default: ({ value, onSelect }: { value: string, onSelect: (key: string) => void }) => (
<button data-testid="priority-selector" onClick={() => onSelect('custom')}>
Priority Selector
{' '}
{value}
</button>
),
}))
vi.mock('./priority-use-tip', () => ({
default: () => <div data-testid="priority-use-tip">Priority Tip</div>,
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div data-testid="indicator" data-color={color} />,
default: ({ color }: { color: string }) => <div data-testid="indicator">{color}</div>,
}))
vi.mock('@/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning', () => ({
default: (props: Record<string, unknown>) => <div data-testid="warning-icon" className={props.className as string} />,
}))
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
})
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test-provider',
provider_credential_schema: { credential_form_schemas: [] },
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: 'test-credential',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'test-credential' }],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
supported_model_types: ['llm'],
...overrides,
} as unknown as ModelProvider)
const renderWithQueryClient = (provider: ModelProvider) => {
const queryClient = createTestQueryClient()
return render(
<QueryClientProvider client={queryClient}>
<CredentialPanel provider={provider} />
</QueryClientProvider>,
)
}
describe('CredentialPanel', () => {
const mockProvider: ModelProvider = {
provider: 'test-provider',
provider_credential_schema: true,
custom_configuration: { status: 'active' },
system_configuration: { enabled: true },
preferred_provider_type: 'system',
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
supported_model_types: ['gpt-4'],
} as unknown as ModelProvider
beforeEach(() => {
vi.clearAllMocks()
Object.assign(mockTrialCredits, { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false })
})
describe('Text label variants', () => {
it('should show "AI credits in use" for credits-active variant', () => {
renderWithQueryClient(createProvider())
expect(screen.getByText(/aiCreditsInUse/)).toBeInTheDocument()
})
it('should show "Credits exhausted" for credits-exhausted variant (no credentials)', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByText(/quotaExhausted/)).toBeInTheDocument()
})
it('should show "No available usage" for no-usage variant (exhausted + credential unauthorized)', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1' }],
},
}))
expect(screen.getByText(/noAvailableUsage/)).toBeInTheDocument()
})
it('should show "AI credits in use" with warning for credits-fallback (custom priority, no credentials, credits available)', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByText(/aiCreditsInUse/)).toBeInTheDocument()
})
it('should show "AI credits in use" with warning for credits-fallback (custom priority, credential unauthorized, credits available)', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1' }],
},
}))
expect(screen.getByText(/aiCreditsInUse/)).toBeInTheDocument()
})
it('should show warning icon for credits-fallback variant', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByTestId('warning-icon')).toBeInTheDocument()
Object.assign(mockCredentialStatus, {
hasCredential: true,
authorized: true,
authRemoved: false,
current_credential_name: 'test-credential',
notAllowedToUse: false,
})
})
describe('Status label variants', () => {
it('should show green indicator and credential name for api-fallback (exhausted + authorized key)', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider())
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
expect(screen.getByText('test-credential')).toBeInTheDocument()
})
it('should show credential name and configuration actions', () => {
render(<CredentialPanel provider={mockProvider} />)
it('should show warning icon for api-fallback variant', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider())
expect(screen.getByTestId('warning-icon')).toBeInTheDocument()
})
expect(screen.getByText('test-credential')).toBeInTheDocument()
expect(screen.getByTestId('config-provider')).toBeInTheDocument()
expect(screen.getByTestId('priority-selector')).toBeInTheDocument()
})
it('should show green indicator for api-active (custom priority + authorized)', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
expect(screen.getByText('test-credential')).toBeInTheDocument()
})
it('should show unauthorized status label when credential is missing', () => {
mockCredentialStatus.hasCredential = false
render(<CredentialPanel provider={mockProvider} />)
it('should NOT show warning icon for api-active variant', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(screen.queryByTestId('warning-icon')).not.toBeInTheDocument()
})
expect(screen.getByText(/modelProvider\.auth\.unAuthorized/)).toBeInTheDocument()
})
it('should show red indicator and "Unavailable" for api-unavailable (exhausted + named unauthorized key)', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: 'Bad Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
}))
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'red')
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
expect(screen.getByText('Bad Key')).toBeInTheDocument()
it('should show removed credential label and priority tip for custom preference', () => {
mockCredentialStatus.authorized = false
mockCredentialStatus.authRemoved = true
render(<CredentialPanel provider={{ ...mockProvider, preferred_provider_type: 'custom' } as ModelProvider} />)
expect(screen.getByText(/modelProvider\.auth\.authRemoved/)).toBeInTheDocument()
expect(screen.getByTestId('priority-use-tip')).toBeInTheDocument()
})
it('should change priority and refresh related data after success', async () => {
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
mockChangePriority.mockResolvedValue({ result: 'success' })
render(<CredentialPanel provider={mockProvider} />)
fireEvent.click(screen.getByTestId('priority-selector'))
await waitFor(() => {
expect(mockChangePriority).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalled()
expect(mockUpdateModelProviders).toHaveBeenCalled()
expect(mockUpdateModelList).toHaveBeenCalledWith('gpt-4')
expect(mockEventEmitter.emit).toHaveBeenCalled()
})
})
describe('Destructive styling', () => {
it('should apply destructive container for credits-exhausted', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy()
})
it('should apply destructive container for no-usage variant', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1' }],
},
}))
expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy()
})
it('should apply destructive container for api-unavailable variant', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: 'Bad Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
}))
expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy()
})
it('should apply default container for credits-active', () => {
const { container } = renderWithQueryClient(createProvider())
expect(container.querySelector('[class*="bg-white"]')).toBeTruthy()
})
it('should apply default container for api-active', () => {
const { container } = renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(container.querySelector('[class*="bg-white"]')).toBeTruthy()
})
it('should apply default container for api-fallback', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider())
expect(container.querySelector('[class*="bg-white"]')).toBeTruthy()
})
})
describe('Text color', () => {
it('should use destructive text color for credits-exhausted label', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(container.querySelector('.text-text-destructive')).toBeTruthy()
})
it('should use secondary text color for credits-active label', () => {
const { container } = renderWithQueryClient(createProvider())
expect(container.querySelector('.text-text-secondary')).toBeTruthy()
})
})
describe('Priority change', () => {
it('should call mutation with correct params on priority change', async () => {
renderWithQueryClient(createProvider())
await act(async () => {
fireEvent.click(screen.getByTestId('change-priority-btn'))
})
await waitFor(() => {
expect(mockChangePriorityFn.mock.calls[0]?.[0]).toEqual({
params: { provider: 'test-provider' },
body: { preferred_provider_type: 'custom' },
})
})
})
it('should show success toast and refresh data after successful mutation', async () => {
renderWithQueryClient(createProvider())
await act(async () => {
fireEvent.click(screen.getByTestId('change-priority-btn'))
})
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success' }),
)
expect(mockUpdateModelProviders).toHaveBeenCalled()
expect(mockUpdateModelList).toHaveBeenCalledWith('llm')
})
})
})
describe('ModelAuthDropdown integration', () => {
it('should pass credits-active variant to dropdown when credits available', () => {
renderWithQueryClient(createProvider())
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-active')
})
it('should pass api-fallback variant to dropdown when exhausted with valid key', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider())
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-fallback')
})
it('should pass credits-exhausted variant when exhausted with no credentials', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-exhausted')
})
it('should pass api-active variant for custom priority with authorized key', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-active')
})
it('should pass credits-fallback variant for custom priority with no credentials and credits available', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-fallback')
})
it('should pass credits-fallback variant for custom priority with named unauthorized key and credits available', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: 'Bad Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-fallback')
})
it('should pass no-usage variant when exhausted + credential but unauthorized', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1' }],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'no-usage')
})
})
describe('apiKeyOnly priority (system disabled)', () => {
it('should derive api-required-add when system config disabled and no credentials', () => {
renderWithQueryClient(createProvider({
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-required-add')
expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument()
})
it('should derive api-active when system config disabled but has authorized key', () => {
renderWithQueryClient(createProvider({
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-active')
})
it('should render standalone priority selector without provider schema', () => {
const providerNoSchema = {
...mockProvider,
provider_credential_schema: null,
} as unknown as ModelProvider
render(<CredentialPanel provider={providerNoSchema} />)
expect(screen.getByTestId('priority-selector')).toBeInTheDocument()
expect(screen.queryByTestId('config-provider')).not.toBeInTheDocument()
})
})

View File

@@ -1,161 +1,151 @@
import type {
ModelProvider,
PreferredProviderTypeEnum,
} from '../declarations'
import type { CardVariant } from './use-credential-panel-state'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { memo } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Warning from '@/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning'
import Toast from '@/app/components/base/toast'
import { useToastContext } from '@/app/components/base/toast/context'
import { ConfigProvider } from '@/app/components/header/account-setting/model-provider-page/model-auth'
import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
import Indicator from '@/app/components/header/indicator'
import { consoleQuery } from '@/service/client'
import { IS_CLOUD_EDITION } from '@/config'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { changeModelProviderPriority } from '@/service/common'
import { cn } from '@/utils/classnames'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
} from '../declarations'
import {
useUpdateModelList,
useUpdateModelProviders,
} from '../hooks'
import ModelAuthDropdown from './model-auth-dropdown'
import SystemQuotaCard from './system-quota-card'
import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state'
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './index'
import PrioritySelector from './priority-selector'
import PriorityUseTip from './priority-use-tip'
type CredentialPanelProps = {
provider: ModelProvider
}
const TEXT_LABEL_VARIANTS = new Set<CardVariant>([
'credits-active',
'credits-fallback',
'credits-exhausted',
'no-usage',
'api-required-add',
'api-required-configure',
])
const CredentialPanel = ({
provider,
}: CredentialPanelProps) => {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { notify } = useToastContext()
const { eventEmitter } = useEventEmitterContextContext()
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const state = useCredentialPanelState(provider)
const providerName = provider.provider
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: providerName,
},
},
})
const customConfig = provider.custom_configuration
const systemConfig = provider.system_configuration
const priorityUseType = provider.preferred_provider_type
const isCustomConfigured = customConfig.status === CustomConfigurationStatusEnum.active
const configurateMethods = provider.configurate_methods
const {
hasCredential,
authorized,
authRemoved,
current_credential_name,
notAllowedToUse,
} = useCredentialStatus(provider)
const { mutate: changePriority, isPending: isChangingPriority } = useMutation(
consoleQuery.modelProviders.changePreferredProviderType.mutationOptions({
onSuccess: () => {
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
queryClient.invalidateQueries({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'none',
})
updateModelProviders()
provider.configurate_methods.forEach((method) => {
if (method === ConfigurationMethodEnum.predefinedModel)
provider.supported_model_types.forEach(modelType => updateModelList(modelType))
})
},
onError: () => {
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
}),
)
const showPrioritySelector = systemConfig.enabled && isCustomConfigured && IS_CLOUD_EDITION
const handleChangePriority = (key: PreferredProviderTypeEnum) => {
changePriority({
params: { provider: providerName },
body: { preferred_provider_type: key },
const handleChangePriority = async (key: PreferredProviderTypeEnum) => {
const res = await changeModelProviderPriority({
url: `/workspaces/current/model-providers/${provider.provider}/preferred-provider-type`,
body: {
preferred_provider_type: key,
},
})
if (res.result === 'success') {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
updateModelProviders()
configurateMethods.forEach((method) => {
if (method === ConfigurationMethodEnum.predefinedModel)
provider.supported_model_types.forEach(modelType => updateModelList(modelType))
})
eventEmitter?.emit({
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
payload: provider.provider,
} as any)
}
}
const credentialLabel = useMemo(() => {
if (!hasCredential)
return t('modelProvider.auth.unAuthorized', { ns: 'common' })
if (authorized)
return current_credential_name
if (authRemoved)
return t('modelProvider.auth.authRemoved', { ns: 'common' })
const { variant, credentialName } = state
const isDestructive = isDestructiveVariant(variant)
const isTextLabel = TEXT_LABEL_VARIANTS.has(variant)
const needsGap = !isTextLabel || variant === 'credits-fallback'
return ''
}, [authorized, authRemoved, current_credential_name, hasCredential])
return (
<SystemQuotaCard variant={isDestructive ? 'destructive' : 'default'}>
<SystemQuotaCard.Label className={needsGap ? 'gap-1' : undefined}>
{isTextLabel
? <TextLabel variant={variant} />
: <StatusLabel variant={variant} credentialName={credentialName} />}
</SystemQuotaCard.Label>
<SystemQuotaCard.Actions>
<ModelAuthDropdown
provider={provider}
state={state}
isChangingPriority={isChangingPriority}
onChangePriority={handleChangePriority}
/>
</SystemQuotaCard.Actions>
</SystemQuotaCard>
)
}
const TEXT_LABEL_KEYS = {
'credits-active': 'modelProvider.card.aiCreditsInUse',
'credits-fallback': 'modelProvider.card.aiCreditsInUse',
'credits-exhausted': 'modelProvider.card.quotaExhausted',
'no-usage': 'modelProvider.card.noAvailableUsage',
'api-required-add': 'modelProvider.card.apiKeyRequired',
'api-required-configure': 'modelProvider.card.apiKeyRequired',
} as const satisfies Partial<Record<CardVariant, string>>
function TextLabel({ variant }: { variant: CardVariant }) {
const { t } = useTranslation()
const isDestructive = isDestructiveVariant(variant)
const labelKey = TEXT_LABEL_KEYS[variant as keyof typeof TEXT_LABEL_KEYS]
const color = useMemo(() => {
if (authRemoved || !hasCredential)
return 'red'
if (notAllowedToUse)
return 'gray'
return 'green'
}, [authRemoved, notAllowedToUse, hasCredential])
return (
<>
<span className={isDestructive ? 'text-text-destructive' : 'text-text-secondary'}>
{t(labelKey, { ns: 'common' })}
</span>
{variant === 'credits-fallback' && (
<Warning className="h-3 w-3 shrink-0 text-text-warning" />
)}
{
provider.provider_credential_schema && (
<div className={cn(
'relative ml-1 w-[120px] shrink-0 rounded-lg border-[0.5px] border-components-panel-border bg-white/[0.18] p-1',
authRemoved && 'border-state-destructive-border bg-state-destructive-hover',
)}
>
<div className="system-xs-medium mb-1 flex h-5 items-center justify-between pl-2 pr-[7px] pt-1 text-text-tertiary">
<div
className={cn(
'grow truncate',
authRemoved && 'text-text-destructive',
)}
title={credentialLabel}
>
{credentialLabel}
</div>
<Indicator className="shrink-0" color={color} />
</div>
<div className="flex items-center gap-0.5">
<ConfigProvider
provider={provider}
/>
{
showPrioritySelector && (
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
)
}
</div>
{
priorityUseType === PreferredProviderTypeEnum.custom && systemConfig.enabled && (
<PriorityUseTip />
)
}
</div>
)
}
{
showPrioritySelector && !provider.provider_credential_schema && (
<div className="ml-1">
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
</div>
)
}
</>
)
}
function StatusLabel({ variant, credentialName }: {
variant: CardVariant
credentialName: string | undefined
}) {
const { t } = useTranslation()
const dotColor = variant === 'api-unavailable' ? 'red' : 'green'
const showWarning = variant === 'api-fallback'
return (
<>
<Indicator className="shrink-0" color={dotColor} />
<span
className="truncate text-text-secondary"
title={credentialName}
>
{credentialName}
</span>
{showWarning && (
<Warning className="h-3 w-3 shrink-0 text-text-warning" />
)}
{variant === 'api-unavailable' && (
<span className="shrink-0 text-text-destructive system-2xs-medium">
{t('modelProvider.card.unavailable', { ns: 'common' })}
</span>
)}
</>
)
}
export default memo(CredentialPanel)
export default CredentialPanel

View File

@@ -1,28 +1,17 @@
import type { ReactNode } from 'react'
import type { ModelProvider } from '../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ModelItem, ModelProvider } from '../declarations'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { useExpandModelProviderList } from '../atoms'
import { fetchModelProviderModelList } from '@/service/common'
import { ConfigurationMethodEnum } from '../declarations'
import ProviderAddedCard from './index'
let mockIsCurrentWorkspaceManager = true
const mockFetchModelProviderModels = vi.fn()
const mockQueryOptions = vi.fn(({ input, ...options }: { input: { params: { provider: string } }, enabled?: boolean }) => ({
queryKey: ['console', 'modelProviders', 'models', input.params.provider],
queryFn: () => mockFetchModelProviderModels(input.params.provider),
...options,
}))
const mockEventEmitter = {
useSubscription: vi.fn(),
emit: vi.fn(),
}
vi.mock('@/service/client', () => ({
consoleQuery: {
modelProviders: {
models: {
queryOptions: (options: { input: { params: { provider: string } }, enabled?: boolean }) => mockQueryOptions(options),
},
},
},
vi.mock('@/service/common', () => ({
fetchModelProviderModelList: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
@@ -31,6 +20,12 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: mockEventEmitter,
}),
}))
// Mock internal components to simplify testing of the index file
vi.mock('./credential-panel', () => ({
default: () => <div data-testid="credential-panel" />,
@@ -58,38 +53,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth'
ManageCustomModelCredentials: () => <div data-testid="manage-custom-model" />,
}))
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
},
})
const renderWithQueryClient = (node: ReactNode) => {
const queryClient = createTestQueryClient()
const store = createStore()
return render(
<JotaiProvider store={store}>
<QueryClientProvider client={queryClient}>
{node}
</QueryClientProvider>
</JotaiProvider>,
)
}
const ExternalExpandControls = () => {
const expandModelProviderList = useExpandModelProviderList()
return (
<>
<button type="button" data-testid="expand-other-provider" onClick={() => expandModelProviderList('langgenius/anthropic/anthropic')}>
expand other
</button>
<button type="button" data-testid="expand-current-provider" onClick={() => expandModelProviderList('langgenius/openai/openai')}>
expand current
</button>
</>
)
}
describe('ProviderAddedCard', () => {
const mockProvider = {
provider: 'langgenius/openai/openai',
@@ -104,21 +67,19 @@ describe('ProviderAddedCard', () => {
})
it('should render provider added card component', () => {
renderWithQueryClient(<ProviderAddedCard provider={mockProvider} />)
render(<ProviderAddedCard provider={mockProvider} />)
expect(screen.getByTestId('provider-added-card')).toBeInTheDocument()
expect(screen.getByTestId('provider-icon')).toBeInTheDocument()
})
it('should open, refresh and collapse model list', async () => {
mockFetchModelProviderModels.mockResolvedValue({ data: [{ model: 'gpt-4' }] })
renderWithQueryClient(<ProviderAddedCard provider={mockProvider} />)
vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: [{ model: 'gpt-4' }] } as unknown as { data: ModelItem[] })
render(<ProviderAddedCard provider={mockProvider} />)
const showModelsBtn = screen.getByTestId('show-models-button')
fireEvent.click(showModelsBtn)
await waitFor(() => {
expect(mockFetchModelProviderModels).toHaveBeenCalledWith(mockProvider.provider)
})
expect(fetchModelProviderModelList).toHaveBeenCalledWith(`/workspaces/current/model-providers/${mockProvider.provider}/models`)
expect(await screen.findByTestId('model-list')).toBeInTheDocument()
// Test line 71-72: Opening when already fetched
@@ -129,13 +90,13 @@ describe('ProviderAddedCard', () => {
// Explicitly re-find and click to re-open
fireEvent.click(screen.getByTestId('show-models-button'))
expect(await screen.findByTestId('model-list')).toBeInTheDocument()
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(2) // Re-open fetches again with default stale/gc behavior
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1) // Should not fetch again
// Refresh list from ModelList
const refreshBtn = screen.getByRole('button', { name: 'refresh list' })
fireEvent.click(refreshBtn)
await waitFor(() => {
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(3)
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(2)
})
})
@@ -144,20 +105,18 @@ describe('ProviderAddedCard', () => {
const promise = new Promise((resolve) => {
resolveOuter = resolve
})
mockFetchModelProviderModels.mockReturnValue(promise)
vi.mocked(fetchModelProviderModelList).mockReturnValue(promise as unknown as ReturnType<typeof fetchModelProviderModelList>)
renderWithQueryClient(<ProviderAddedCard provider={mockProvider} />)
render(<ProviderAddedCard provider={mockProvider} />)
const showModelsBtn = screen.getByTestId('show-models-button')
// First call sets loading to true
fireEvent.click(showModelsBtn)
await waitFor(() => {
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(1)
})
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
// Second call should return early because loading is true
fireEvent.click(showModelsBtn)
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(1)
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
await act(async () => {
resolveOuter({ data: [] })
@@ -166,49 +125,56 @@ describe('ProviderAddedCard', () => {
expect(await screen.findByTestId('model-list')).toBeInTheDocument()
})
it('should only react to external expansion for the matching provider', async () => {
mockFetchModelProviderModels.mockResolvedValue({ data: [{ model: 'gpt-4' }] })
renderWithQueryClient(
<>
<ProviderAddedCard provider={mockProvider} />
<ExternalExpandControls />
</>,
)
fireEvent.click(screen.getByTestId('expand-other-provider'))
await waitFor(() => {
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(0)
})
fireEvent.click(screen.getByTestId('expand-current-provider'))
await waitFor(() => {
expect(mockFetchModelProviderModels).toHaveBeenCalledWith(mockProvider.provider)
})
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(1)
})
it('should render configure tip when provider is not in quota list and not configured', () => {
const providerWithoutQuota = {
...mockProvider,
provider: 'custom/provider',
} as unknown as ModelProvider
renderWithQueryClient(<ProviderAddedCard provider={providerWithoutQuota} notConfigured />)
render(<ProviderAddedCard provider={providerWithoutQuota} notConfigured />)
expect(screen.getByText('common.modelProvider.configureTip')).toBeInTheDocument()
})
it('should refresh model list on event subscription', async () => {
let capturedHandler: (v: { type: string, payload: string } | null) => void = () => { }
mockEventEmitter.useSubscription.mockImplementation((handler: (v: unknown) => void) => {
capturedHandler = handler as (v: { type: string, payload: string } | null) => void
})
vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: [] } as unknown as { data: ModelItem[] })
render(<ProviderAddedCard provider={mockProvider} />)
expect(capturedHandler).toBeDefined()
act(() => {
capturedHandler({
type: 'UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST',
payload: mockProvider.provider,
})
})
await waitFor(() => {
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
})
// Should ignore non-matching events
act(() => {
capturedHandler({ type: 'OTHER', payload: '' })
capturedHandler(null)
})
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
})
it('should render custom model actions for workspace managers', () => {
const customConfigProvider = {
...mockProvider,
configurate_methods: [ConfigurationMethodEnum.customizableModel],
} as unknown as ModelProvider
const { unmount } = renderWithQueryClient(<ProviderAddedCard provider={customConfigProvider} />)
const { rerender } = render(<ProviderAddedCard provider={customConfigProvider} />)
expect(screen.getByTestId('manage-custom-model')).toBeInTheDocument()
expect(screen.getByTestId('add-custom-model')).toBeInTheDocument()
unmount()
mockIsCurrentWorkspaceManager = false
renderWithQueryClient(<ProviderAddedCard provider={customConfigProvider} />)
rerender(<ProviderAddedCard provider={customConfigProvider} />)
expect(screen.queryByTestId('manage-custom-model')).not.toBeInTheDocument()
})
})

View File

@@ -1,12 +1,11 @@
import type { FC } from 'react'
import type {
ModelItem,
ModelProvider,
} from '../declarations'
import type { ModelProviderQuotaGetPaid } from '../utils'
import type { PluginDetail } from '@/app/components/plugins/types'
import { useQuery } from '@tanstack/react-query'
import { memo, useCallback } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
AddCustomModel,
@@ -14,10 +13,9 @@ import {
} from '@/app/components/header/account-setting/model-provider-page/model-auth'
import { IS_CE_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContextSelector } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { fetchModelProviderModelList } from '@/service/common'
import { cn } from '@/utils/classnames'
import { useModelProviderListExpanded, useSetModelProviderListExpanded } from '../atoms'
import { ConfigurationMethodEnum } from '../declarations'
import ModelBadge from '../model-badge'
import ProviderIcon from '../provider-icon'
@@ -27,123 +25,121 @@ import {
} from '../utils'
import CredentialPanel from './credential-panel'
import ModelList from './model-list'
import ProviderCardActions from './provider-card-actions'
export const UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST = 'UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST'
type ProviderAddedCardProps = {
notConfigured?: boolean
provider: ModelProvider
pluginDetail?: PluginDetail
}
const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
notConfigured,
provider,
pluginDetail,
}) => {
const { t } = useTranslation()
const refreshModelProviders = useProviderContextSelector(state => state.refreshModelProviders)
const currentProviderName = provider.provider
const expanded = useModelProviderListExpanded(currentProviderName)
const setExpanded = useSetModelProviderListExpanded(currentProviderName)
const supportsPredefinedModel = provider.configurate_methods.includes(ConfigurationMethodEnum.predefinedModel)
const supportsCustomizableModel = provider.configurate_methods.includes(ConfigurationMethodEnum.customizableModel)
const { eventEmitter } = useEventEmitterContextContext()
const [fetched, setFetched] = useState(false)
const [loading, setLoading] = useState(false)
const [collapsed, setCollapsed] = useState(true)
const [modelList, setModelList] = useState<ModelItem[]>([])
const configurationMethods = provider.configurate_methods.filter(method => method !== ConfigurationMethodEnum.fetchFromRemote)
const systemConfig = provider.system_configuration
const {
data: modelList = [],
isFetching: loading,
isSuccess: hasFetchedModelList,
refetch: refetchModelList,
} = useQuery(consoleQuery.modelProviders.models.queryOptions({
input: { params: { provider: currentProviderName } },
enabled: expanded,
refetchOnWindowFocus: false,
select: response => response.data,
}))
const hasModelList = hasFetchedModelList && !!modelList.length
const showCollapsedSection = !expanded || !hasFetchedModelList
const hasModelList = fetched && !!modelList.length
const { isCurrentWorkspaceManager } = useAppContext()
const showModelProvider = systemConfig.enabled && MODEL_PROVIDER_QUOTA_GET_PAID.includes(currentProviderName as ModelProviderQuotaGetPaid) && !IS_CE_EDITION
const showCredential = supportsPredefinedModel && isCurrentWorkspaceManager
const showCustomModelActions = supportsCustomizableModel && isCurrentWorkspaceManager
const showModelProvider = systemConfig.enabled && MODEL_PROVIDER_QUOTA_GET_PAID.includes(provider.provider as ModelProviderQuotaGetPaid) && !IS_CE_EDITION
const showCredential = configurationMethods.includes(ConfigurationMethodEnum.predefinedModel) && isCurrentWorkspaceManager
const refreshModelList = useCallback((targetProviderName: string) => {
if (targetProviderName !== currentProviderName)
return
if (!expanded)
setExpanded(true)
refetchModelList().catch(() => {})
}, [currentProviderName, expanded, refetchModelList, setExpanded])
const handleOpenModelList = useCallback(() => {
const getModelList = async (providerName: string) => {
if (loading)
return
if (!expanded) {
setExpanded(true)
try {
setLoading(true)
const modelsData = await fetchModelProviderModelList(`/workspaces/current/model-providers/${providerName}/models`)
setModelList(modelsData.data)
setCollapsed(false)
setFetched(true)
}
finally {
setLoading(false)
}
}
const handleOpenModelList = () => {
if (fetched) {
setCollapsed(false)
return
}
refetchModelList().catch(() => {})
}, [expanded, loading, refetchModelList, setExpanded])
getModelList(provider.provider)
}
eventEmitter?.useSubscription((v: any) => {
if (v?.type === UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST && v.payload === provider.provider)
getModelList(v.payload)
})
return (
<div
data-testid="provider-added-card"
className={cn(
'mb-2 rounded-xl border-[0.5px] border-divider-regular bg-third-party-model-bg-default shadow-xs',
currentProviderName === 'langgenius/openai/openai' && 'bg-third-party-model-bg-openai',
currentProviderName === 'langgenius/anthropic/anthropic' && 'bg-third-party-model-bg-anthropic',
provider.provider === 'langgenius/openai/openai' && 'bg-third-party-model-bg-openai',
provider.provider === 'langgenius/anthropic/anthropic' && 'bg-third-party-model-bg-anthropic',
)}
>
<div className="flex rounded-t-xl py-2 pl-3 pr-2">
<div className="grow px-1 pb-0.5 pt-1">
<div className="mb-2 flex items-center gap-1">
<ProviderIcon provider={provider} />
{pluginDetail && (
<ProviderCardActions
detail={pluginDetail}
onUpdate={refreshModelProviders}
/>
)}
</div>
<div className="flex gap-0.5">
{provider.supported_model_types.map(modelType => (
<ModelBadge key={modelType}>
{modelTypeFormat(modelType)}
</ModelBadge>
))}
</div>
</div>
{showCredential && (
<CredentialPanel
<ProviderIcon
className="mb-2"
provider={provider}
/>
)}
<div className="flex gap-0.5">
{
provider.supported_model_types.map(modelType => (
<ModelBadge key={modelType}>
{modelTypeFormat(modelType)}
</ModelBadge>
))
}
</div>
</div>
{
showCredential && (
<CredentialPanel
provider={provider}
/>
)
}
</div>
{
showCollapsedSection && (
collapsed && (
<div className="group flex items-center justify-between border-t border-t-divider-subtle py-1.5 pl-2 pr-[11px] text-text-tertiary system-xs-medium">
{(showModelProvider || !notConfigured) && (
<button
type="button"
data-testid="show-models-button"
className="flex h-6 items-center rounded-lg pl-1 pr-1.5 hover:bg-components-button-ghost-bg-hover"
aria-label={t('modelProvider.showModels', { ns: 'common' })}
onClick={handleOpenModelList}
>
{
hasModelList
? t('modelProvider.modelsNum', { ns: 'common', num: modelList.length })
: t('modelProvider.showModels', { ns: 'common' })
}
{!loading && <div className="i-ri-arrow-right-s-line h-4 w-4" />}
{
loading && (
<div className="i-ri-loader-2-line ml-0.5 h-3 w-3 animate-spin" />
)
}
</button>
<>
<div className="flex h-6 items-center pl-1 pr-1.5 leading-6 group-hover:hidden">
{
hasModelList
? t('modelProvider.modelsNum', { ns: 'common', num: modelList.length })
: t('modelProvider.showModels', { ns: 'common' })
}
{!loading && <div className="i-ri-arrow-right-s-line h-4 w-4" />}
</div>
<div
data-testid="show-models-button"
className="hidden h-6 cursor-pointer items-center rounded-lg pl-1 pr-1.5 hover:bg-components-button-ghost-bg-hover group-hover:flex"
onClick={handleOpenModelList}
>
{
hasModelList
? t('modelProvider.showModelsNum', { ns: 'common', num: modelList.length })
: t('modelProvider.showModels', { ns: 'common' })
}
{!loading && <div className="i-ri-arrow-right-s-line h-4 w-4" />}
{
loading && (
<div className="i-ri-loader-2-line ml-0.5 h-3 w-3 animate-spin" />
)
}
</div>
</>
)}
{!showModelProvider && notConfigured && (
<div className="flex h-6 items-center pl-1 pr-1.5">
@@ -152,7 +148,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
</div>
)}
{
showCustomModelActions && (
configurationMethods.includes(ConfigurationMethodEnum.customizableModel) && isCurrentWorkspaceManager && (
<div className="flex grow justify-end">
<ManageCustomModelCredentials
provider={provider}
@@ -170,12 +166,12 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
)
}
{
!showCollapsedSection && (
!collapsed && (
<ModelList
provider={provider}
models={modelList}
onCollapse={() => setExpanded(false)}
onChange={refreshModelList}
onCollapse={() => setCollapsed(true)}
onChange={(provider: string) => getModelList(provider)}
/>
)
}
@@ -183,4 +179,4 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
)
}
export default memo(ProviderAddedCard)
export default ProviderAddedCard

View File

@@ -1,142 +0,0 @@
import type { Credential, ModelProvider } from '../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../declarations'
import ApiKeySection from './api-key-section'
const createCredential = (overrides: Partial<Credential> = {}): Credential => ({
credential_id: 'cred-1',
credential_name: 'Test API Key',
...overrides,
})
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test-provider',
allow_custom_token: true,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
...overrides,
} as unknown as ModelProvider)
describe('ApiKeySection', () => {
const handlers = {
onItemClick: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
onAdd: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Empty state
describe('Empty state (no credentials)', () => {
it('should show empty state message', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={[]}
selectedCredentialId={undefined}
{...handlers}
/>,
)
expect(screen.getByText(/noApiKeysTitle/)).toBeInTheDocument()
expect(screen.getByText(/noApiKeysDescription/)).toBeInTheDocument()
})
it('should show Add API Key button', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={[]}
selectedCredentialId={undefined}
{...handlers}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should call onAdd when Add API Key is clicked', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={[]}
selectedCredentialId={undefined}
{...handlers}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /addApiKey/ }))
expect(handlers.onAdd).toHaveBeenCalledTimes(1)
})
it('should hide Add API Key button when allow_custom_token is false', () => {
render(
<ApiKeySection
provider={createProvider({ allow_custom_token: false })}
credentials={[]}
selectedCredentialId={undefined}
{...handlers}
/>,
)
expect(screen.queryByRole('button', { name: /addApiKey/ })).not.toBeInTheDocument()
})
})
// With credentials
describe('With credentials', () => {
const credentials = [
createCredential({ credential_id: 'cred-1', credential_name: 'Key Alpha' }),
createCredential({ credential_id: 'cred-2', credential_name: 'Key Beta' }),
]
it('should render credential list with header', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={credentials}
selectedCredentialId="cred-1"
{...handlers}
/>,
)
expect(screen.getByText(/apiKeys/)).toBeInTheDocument()
expect(screen.getByText('Key Alpha')).toBeInTheDocument()
expect(screen.getByText('Key Beta')).toBeInTheDocument()
})
it('should show Add API Key button in footer', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={credentials}
selectedCredentialId="cred-1"
{...handlers}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should hide Add API Key when allow_custom_token is false', () => {
render(
<ApiKeySection
provider={createProvider({ allow_custom_token: false })}
credentials={credentials}
selectedCredentialId="cred-1"
{...handlers}
/>,
)
expect(screen.queryByRole('button', { name: /addApiKey/ })).not.toBeInTheDocument()
})
})
})

View File

@@ -1,91 +0,0 @@
import type { Credential, CustomModel, ModelProvider } from '../../declarations'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CredentialItem from '../../model-auth/authorized/credential-item'
type ApiKeySectionProps = {
provider: ModelProvider
credentials: Credential[]
selectedCredentialId: string | undefined
isActivating?: boolean
onItemClick: (credential: Credential, model?: CustomModel) => void
onEdit: (credential?: Credential) => void
onDelete: (credential?: Credential) => void
onAdd: () => void
}
function ApiKeySection({
provider,
credentials,
selectedCredentialId,
isActivating,
onItemClick,
onEdit,
onDelete,
onAdd,
}: ApiKeySectionProps) {
const { t } = useTranslation()
const notAllowCustomCredential = provider.allow_custom_token === false
if (!credentials.length) {
return (
<div className="flex flex-col gap-2 p-2">
<div className="rounded-[10px] bg-gradient-to-r from-state-base-hover to-transparent p-4">
<div className="flex flex-col gap-1">
<div className="text-text-secondary system-sm-medium">
{t('modelProvider.card.noApiKeysTitle', { ns: 'common' })}
</div>
<div className="text-text-tertiary system-xs-regular">
{t('modelProvider.card.noApiKeysDescription', { ns: 'common' })}
</div>
</div>
</div>
{!notAllowCustomCredential && (
<Button
onClick={onAdd}
className="w-full"
>
{t('modelProvider.auth.addApiKey', { ns: 'common' })}
</Button>
)}
</div>
)
}
return (
<div className="border-t border-t-divider-subtle">
<div className="px-1">
<div className="pb-1 pl-7 pr-2 pt-3 text-text-tertiary system-xs-medium-uppercase">
{t('modelProvider.auth.apiKeys', { ns: 'common' })}
</div>
<div className="max-h-[200px] overflow-y-auto">
{credentials.map(credential => (
<CredentialItem
key={credential.credential_id}
credential={credential}
disabled={isActivating}
showSelectedIcon
selectedCredentialId={selectedCredentialId}
onItemClick={onItemClick}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
</div>
{!notAllowCustomCredential && (
<div className="p-2">
<Button
onClick={onAdd}
className="w-full"
>
{t('modelProvider.auth.addApiKey', { ns: 'common' })}
</Button>
</div>
)}
</div>
)
}
export default memo(ApiKeySection)

View File

@@ -1,63 +0,0 @@
import { render, screen } from '@testing-library/react'
import CreditsExhaustedAlert from './credits-exhausted-alert'
const mockTrialCredits = { credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false, nextCreditResetDate: undefined }
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
}))
describe('CreditsExhaustedAlert', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.assign(mockTrialCredits, { credits: 0 })
})
// Without API key fallback
describe('Without API key fallback', () => {
it('should show exhausted message', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(screen.getByText(/creditsExhaustedMessage/)).toBeInTheDocument()
})
it('should show description with upgrade link', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(screen.getByText(/creditsExhaustedDescription/)).toBeInTheDocument()
})
})
// With API key fallback
describe('With API key fallback', () => {
it('should show fallback message', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback />)
expect(screen.getByText(/creditsExhaustedFallback(?!Description)/)).toBeInTheDocument()
})
it('should show fallback description', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback />)
expect(screen.getByText(/creditsExhaustedFallbackDescription/)).toBeInTheDocument()
})
})
// Usage display
describe('Usage display', () => {
it('should show usage label', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(screen.getByText(/usageLabel/)).toBeInTheDocument()
})
it('should show usage amounts', () => {
mockTrialCredits.credits = 200
render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(screen.getByText(/9,800/)).toBeInTheDocument()
expect(screen.getByText(/10,000/)).toBeInTheDocument()
})
})
})

View File

@@ -1,78 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { ICurrentWorkspace } from '@/models/common'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import CreditsExhaustedAlert from './credits-exhausted-alert'
const baseWorkspace: ICurrentWorkspace = {
id: 'ws-1',
name: 'Test Workspace',
plan: 'sandbox',
status: 'normal',
created_at: Date.now(),
role: 'owner',
providers: [],
trial_credits: 200,
trial_credits_used: 200,
next_credit_reset_date: Date.now() + 86400000,
}
function createSeededQueryClient(overrides?: Partial<ICurrentWorkspace>) {
const qc = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: false } },
})
qc.setQueryData(['common', 'current-workspace'], { ...baseWorkspace, ...overrides })
return qc
}
const meta = {
title: 'ModelProvider/CreditsExhaustedAlert',
component: CreditsExhaustedAlert,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Alert shown when trial credits are exhausted, with usage progress bar and upgrade link.',
},
},
},
tags: ['autodocs'],
decorators: [
(Story) => {
return (
<QueryClientProvider client={createSeededQueryClient()}>
<div className="w-[320px]">
<Story />
</div>
</QueryClientProvider>
)
},
],
args: {
hasApiKeyFallback: false,
},
} satisfies Meta<typeof CreditsExhaustedAlert>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithApiKeyFallback: Story = {
args: {
hasApiKeyFallback: true,
},
}
export const PartialUsage: Story = {
decorators: [
(Story) => {
return (
<QueryClientProvider client={createSeededQueryClient({ trial_credits: 500, trial_credits_used: 480 })}>
<div className="w-[320px]">
<Story />
</div>
</QueryClientProvider>
)
},
],
}

View File

@@ -1,65 +0,0 @@
import { Trans, useTranslation } from 'react-i18next'
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import { useModalContextSelector } from '@/context/modal-context'
import { formatNumber } from '@/utils/format'
import { useTrialCredits } from '../use-trial-credits'
type CreditsExhaustedAlertProps = {
hasApiKeyFallback: boolean
}
export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExhaustedAlertProps) {
const { t } = useTranslation()
const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal)
const { credits, totalCredits } = useTrialCredits()
const titleKey = hasApiKeyFallback
? 'modelProvider.card.creditsExhaustedFallback'
: 'modelProvider.card.creditsExhaustedMessage'
const descriptionKey = hasApiKeyFallback
? 'modelProvider.card.creditsExhaustedFallbackDescription'
: 'modelProvider.card.creditsExhaustedDescription'
const usedCredits = totalCredits - credits
const usagePercent = totalCredits > 0 ? Math.min((usedCredits / totalCredits) * 100, 100) : 100
return (
<div className="mx-2 mb-1 mt-0.5 rounded-lg bg-background-section-burn p-3">
<div className="flex flex-col gap-1">
<div className="text-text-primary system-sm-medium">
{t(titleKey, { ns: 'common' })}
</div>
<div className="text-text-tertiary system-xs-regular">
<Trans
i18nKey={descriptionKey}
ns="common"
components={{
upgradeLink: <span className="cursor-pointer text-text-accent system-xs-medium" onClick={setShowPricingModal} />,
}}
/>
</div>
</div>
<div className="mt-3 flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-text-tertiary system-xs-medium">
{t('modelProvider.card.usageLabel', { ns: 'common' })}
</span>
<div className="flex items-center gap-0.5 text-text-tertiary system-xs-regular">
<CreditsCoin className="h-3 w-3" />
<span>
{formatNumber(usedCredits)}
/
{formatNumber(totalCredits)}
</span>
</div>
</div>
<div className="h-1 overflow-hidden rounded-[6px] bg-components-progress-error-bg">
<div
className="h-full rounded-l-[6px] bg-components-progress-error-progress"
style={{ width: `${usagePercent}%` }}
/>
</div>
</div>
</div>
)
}

View File

@@ -1,28 +0,0 @@
import { useTranslation } from 'react-i18next'
type CreditsFallbackAlertProps = {
hasCredentials: boolean
}
export default function CreditsFallbackAlert({ hasCredentials }: CreditsFallbackAlertProps) {
const { t } = useTranslation()
const titleKey = hasCredentials
? 'modelProvider.card.apiKeyUnavailableFallback'
: 'modelProvider.card.noApiKeysFallback'
return (
<div className="mx-2 mb-1 mt-0.5 rounded-lg bg-background-section-burn p-3">
<div className="flex flex-col gap-1">
<div className="text-text-primary system-sm-medium">
{t(titleKey, { ns: 'common' })}
</div>
{hasCredentials && (
<div className="text-text-tertiary system-xs-regular">
{t('modelProvider.card.apiKeyUnavailableFallbackDescription', { ns: 'common' })}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,435 +0,0 @@
import type { ModelProvider } from '../../declarations'
import type { CredentialPanelState } from '../use-credential-panel-state'
import { fireEvent, render, screen } from '@testing-library/react'
import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../declarations'
import DropdownContent from './dropdown-content'
const mockHandleOpenModal = vi.fn()
const mockActivate = vi.fn()
const mockOpenConfirmDelete = vi.fn()
const mockCloseConfirmDelete = vi.fn()
const mockHandleConfirmDelete = vi.fn()
let mockDeleteCredentialId: string | null = null
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => ({ credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false }),
}))
vi.mock('./use-activate-credential', () => ({
useActivateCredential: () => ({
selectedCredentialId: 'cred-1',
isActivating: false,
activate: mockActivate,
}),
}))
vi.mock('../../model-auth/hooks', () => ({
useAuth: () => ({
openConfirmDelete: mockOpenConfirmDelete,
closeConfirmDelete: mockCloseConfirmDelete,
doingAction: false,
handleConfirmDelete: mockHandleConfirmDelete,
deleteCredentialId: mockDeleteCredentialId,
handleOpenModal: mockHandleOpenModal,
}),
}))
vi.mock('../../model-auth/authorized/credential-item', () => ({
default: ({ credential, onItemClick, onEdit, onDelete }: {
credential: { credential_id: string, credential_name: string }
onItemClick?: (c: unknown) => void
onEdit?: (c: unknown) => void
onDelete?: (c: unknown) => void
}) => (
<div data-testid={`credential-${credential.credential_id}`}>
<span>{credential.credential_name}</span>
<button data-testid={`click-${credential.credential_id}`} onClick={() => onItemClick?.(credential)}>select</button>
<button data-testid={`edit-${credential.credential_id}`} onClick={() => onEdit?.(credential)}>edit</button>
<button data-testid={`delete-${credential.credential_id}`} onClick={() => onDelete?.(credential)}>delete</button>
</div>
),
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test',
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: 'My Key',
available_credentials: [
{ credential_id: 'cred-1', credential_name: 'My Key' },
{ credential_id: 'cred-2', credential_name: 'Other Key' },
],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
configurate_methods: ['predefined-model'],
supported_model_types: ['llm'],
...overrides,
} as unknown as ModelProvider)
const createState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({
variant: 'api-active',
priority: 'apiKey',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: true,
isCreditsExhausted: false,
credentialName: 'My Key',
credits: 100,
...overrides,
})
describe('DropdownContent', () => {
const onChangePriority = vi.fn()
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockDeleteCredentialId = null
})
describe('UsagePrioritySection visibility', () => {
it('should show when showPrioritySwitcher is true', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ showPrioritySwitcher: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/usagePriority/)).toBeInTheDocument()
})
it('should hide when showPrioritySwitcher is false', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ showPrioritySwitcher: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/usagePriority/)).not.toBeInTheDocument()
})
})
describe('CreditsExhaustedAlert', () => {
it('should show when credits exhausted and supports credits', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ isCreditsExhausted: true, supportsCredits: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getAllByText(/creditsExhausted/).length).toBeGreaterThan(0)
})
it('should hide when credits not exhausted', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ isCreditsExhausted: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/creditsExhausted/)).not.toBeInTheDocument()
})
it('should hide when credits exhausted but supportsCredits is false', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ isCreditsExhausted: true, supportsCredits: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/creditsExhausted/)).not.toBeInTheDocument()
})
it('should show fallback message when api-fallback variant with exhausted credits', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'api-fallback',
isCreditsExhausted: true,
supportsCredits: true,
priority: 'credits',
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getAllByText(/creditsExhaustedFallback/).length).toBeGreaterThan(0)
})
it('should show non-fallback message when credits-exhausted variant', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'credits-exhausted',
isCreditsExhausted: true,
supportsCredits: true,
hasCredentials: false,
priority: 'credits',
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/creditsExhaustedMessage/)).toBeInTheDocument()
})
})
describe('CreditsFallbackAlert', () => {
it('should show when priority is apiKey, supports credits, not exhausted, and variant is not api-active', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'api-required-add',
priority: 'apiKey',
supportsCredits: true,
isCreditsExhausted: false,
hasCredentials: false,
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/noApiKeysFallback/)).toBeInTheDocument()
})
it('should show unavailable message when priority is apiKey with credentials but not api-active', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'api-unavailable',
priority: 'apiKey',
supportsCredits: true,
isCreditsExhausted: false,
hasCredentials: true,
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getAllByText(/apiKeyUnavailableFallback/).length).toBeGreaterThan(0)
})
it('should NOT show when variant is api-active', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'api-active',
priority: 'apiKey',
supportsCredits: true,
isCreditsExhausted: false,
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/noApiKeysFallback/)).not.toBeInTheDocument()
expect(screen.queryByText(/apiKeyUnavailableFallback/)).not.toBeInTheDocument()
})
it('should NOT show when priority is credits', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'credits-active',
priority: 'credits',
supportsCredits: true,
isCreditsExhausted: false,
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/noApiKeysFallback/)).not.toBeInTheDocument()
expect(screen.queryByText(/apiKeyUnavailableFallback/)).not.toBeInTheDocument()
})
})
describe('API key section', () => {
it('should render all credential items', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText('My Key')).toBeInTheDocument()
expect(screen.getByText('Other Key')).toBeInTheDocument()
})
it('should show empty state when no credentials', () => {
render(
<DropdownContent
provider={createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
})}
state={createState({ hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/noApiKeysTitle/)).toBeInTheDocument()
expect(screen.getByText(/noApiKeysDescription/)).toBeInTheDocument()
})
it('should call activate without closing on credential item click', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByTestId('click-cred-2'))
expect(mockActivate).toHaveBeenCalledWith(
expect.objectContaining({ credential_id: 'cred-2' }),
)
expect(onClose).not.toHaveBeenCalled()
})
it('should call handleOpenModal and close on edit credential', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByTestId('edit-cred-2'))
expect(mockHandleOpenModal).toHaveBeenCalledWith(
expect.objectContaining({ credential_id: 'cred-2' }),
)
expect(onClose).toHaveBeenCalled()
})
it('should call openConfirmDelete on delete credential', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByTestId('delete-cred-2'))
expect(mockOpenConfirmDelete).toHaveBeenCalledWith(
expect.objectContaining({ credential_id: 'cred-2' }),
)
})
})
describe('Add API Key', () => {
it('should call handleOpenModal with no args and close on add', () => {
render(
<DropdownContent
provider={createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
})}
state={createState({ hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /addApiKey/ }))
expect(mockHandleOpenModal).toHaveBeenCalledWith()
expect(onClose).toHaveBeenCalled()
})
})
describe('AlertDialog for delete confirmation', () => {
it('should show confirm dialog when deleteCredentialId is set', () => {
mockDeleteCredentialId = 'cred-1'
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/confirmDelete/)).toBeInTheDocument()
})
it('should not show confirm dialog when deleteCredentialId is null', () => {
mockDeleteCredentialId = null
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/confirmDelete/)).not.toBeInTheDocument()
})
})
describe('Layout', () => {
it('should have 320px width container', () => {
const { container } = render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(container.querySelector('.w-\\[320px\\]')).toBeTruthy()
})
})
})

View File

@@ -1,131 +0,0 @@
import type { Credential, ModelProvider, PreferredProviderTypeEnum } from '../../declarations'
import type { CredentialPanelState } from '../use-credential-panel-state'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { ConfigurationMethodEnum } from '../../declarations'
import { useAuth } from '../../model-auth/hooks'
import ApiKeySection from './api-key-section'
import CreditsExhaustedAlert from './credits-exhausted-alert'
import CreditsFallbackAlert from './credits-fallback-alert'
import UsagePrioritySection from './usage-priority-section'
import { useActivateCredential } from './use-activate-credential'
const EMPTY_CREDENTIALS: Credential[] = []
type DropdownContentProps = {
provider: ModelProvider
state: CredentialPanelState
isChangingPriority: boolean
onChangePriority: (key: PreferredProviderTypeEnum) => void
onClose: () => void
}
function DropdownContent({
provider,
state,
isChangingPriority,
onChangePriority,
onClose,
}: DropdownContentProps) {
const { t } = useTranslation()
const { available_credentials } = provider.custom_configuration
const {
openConfirmDelete,
closeConfirmDelete,
doingAction,
handleConfirmDelete,
deleteCredentialId,
handleOpenModal,
} = useAuth(provider, ConfigurationMethodEnum.predefinedModel)
const { selectedCredentialId, isActivating, activate } = useActivateCredential(provider)
const handleEdit = useCallback((credential?: Credential) => {
handleOpenModal(credential)
onClose()
}, [handleOpenModal, onClose])
const handleDelete = useCallback((credential?: Credential) => {
if (credential)
openConfirmDelete(credential)
}, [openConfirmDelete])
const handleAdd = useCallback(() => {
handleOpenModal()
onClose()
}, [handleOpenModal, onClose])
const showCreditsExhaustedAlert = state.isCreditsExhausted && state.supportsCredits
const hasApiKeyFallback = state.variant === 'api-fallback'
|| (state.variant === 'api-active' && state.priority === 'apiKey')
const showCreditsFallbackAlert = state.priority === 'apiKey'
&& state.supportsCredits
&& !state.isCreditsExhausted
&& state.variant !== 'api-active'
return (
<>
<div className="w-[320px]">
{state.showPrioritySwitcher && (
<UsagePrioritySection
value={state.priority}
disabled={isChangingPriority}
onSelect={onChangePriority}
/>
)}
{showCreditsFallbackAlert && (
<CreditsFallbackAlert hasCredentials={state.hasCredentials} />
)}
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<ApiKeySection
provider={provider}
credentials={available_credentials ?? EMPTY_CREDENTIALS}
selectedCredentialId={selectedCredentialId}
isActivating={isActivating}
onItemClick={activate}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}
/>
</div>
<AlertDialog
open={!!deleteCredentialId}
onOpenChange={(open) => {
if (!open)
closeConfirmDelete()
}}
>
<AlertDialogContent>
<div className="p-6 pb-0">
<AlertDialogTitle className="text-text-primary system-xl-semibold">
{t('modelProvider.confirmDelete', { ns: 'common' })}
</AlertDialogTitle>
<AlertDialogDescription className="mt-1 text-text-secondary system-sm-regular" />
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={doingAction}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton disabled={doingAction} onClick={handleConfirmDelete}>
{t('operation.delete', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default memo(DropdownContent)

View File

@@ -1,211 +0,0 @@
import type { ModelProvider } from '../../declarations'
import type { CredentialPanelState } from '../use-credential-panel-state'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../declarations'
import ModelAuthDropdown from './index'
vi.mock('../../model-auth/hooks', () => ({
useAuth: () => ({
openConfirmDelete: vi.fn(),
closeConfirmDelete: vi.fn(),
doingAction: false,
handleConfirmDelete: vi.fn(),
deleteCredentialId: null,
handleOpenModal: vi.fn(),
}),
}))
vi.mock('./use-activate-credential', () => ({
useActivateCredential: () => ({
selectedCredentialId: undefined,
isActivating: false,
activate: vi.fn(),
}),
}))
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => ({ credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false }),
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test',
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
...overrides,
} as unknown as ModelProvider)
const createState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({
variant: 'credits-active',
priority: 'credits',
supportsCredits: true,
showPrioritySwitcher: false,
hasCredentials: false,
isCreditsExhausted: false,
credentialName: undefined,
credits: 100,
...overrides,
})
describe('ModelAuthDropdown', () => {
const onChangePriority = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Button text', () => {
it('should show "Add API Key" when no credentials for credits-active', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ hasCredentials: false, variant: 'credits-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should show "Configure" when has credentials for api-active', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ hasCredentials: true, variant: 'api-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
it('should show "Add API Key" for api-required-add variant', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-required-add', hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should show "Configure" for api-required-configure variant', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-required-configure', hasCredentials: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
it('should show "Configure" for credits-active when has credentials', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ hasCredentials: true, variant: 'credits-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
it('should show "Add API Key" for credits-exhausted (no credentials)', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'credits-exhausted', hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should show "Configure" for api-unavailable (has credentials)', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-unavailable', hasCredentials: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
it('should show "Configure" for api-fallback (has credentials)', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-fallback', hasCredentials: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
})
describe('Button variant styling', () => {
it('should use secondary-accent for api-required-add', () => {
const { container } = render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-required-add', hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
const button = container.querySelector('button')
expect(button?.getAttribute('data-variant') ?? button?.className).toMatch(/accent/)
})
it('should use secondary-accent for api-required-configure', () => {
const { container } = render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-required-configure', hasCredentials: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
const button = container.querySelector('button')
expect(button?.getAttribute('data-variant') ?? button?.className).toMatch(/accent/)
})
})
describe('Popover behavior', () => {
it('should open popover on button click and show dropdown content', async () => {
render(
<ModelAuthDropdown
provider={createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
current_credential_id: 'c1',
current_credential_name: 'Key 1',
},
})}
state={createState({ hasCredentials: true, variant: 'api-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /config/i }))
await waitFor(() => {
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
})
})
})

View File

@@ -1,85 +0,0 @@
import type { ModelProvider, PreferredProviderTypeEnum } from '../../declarations'
import type { CardVariant, CredentialPanelState } from '../use-credential-panel-state'
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import DropdownContent from './dropdown-content'
type ModelAuthDropdownProps = {
provider: ModelProvider
state: CredentialPanelState
isChangingPriority: boolean
onChangePriority: (key: PreferredProviderTypeEnum) => void
}
const ACCENT_VARIANTS = new Set<CardVariant>([
'api-required-add',
'api-required-configure',
])
function getButtonConfig(variant: CardVariant, hasCredentials: boolean, t: (key: string, opts?: Record<string, string>) => string) {
if (ACCENT_VARIANTS.has(variant)) {
return {
text: variant === 'api-required-add'
? t('modelProvider.auth.addApiKey', { ns: 'common' })
: t('operation.config', { ns: 'common' }),
variant: 'secondary-accent' as const,
}
}
const text = hasCredentials
? t('operation.config', { ns: 'common' })
: t('modelProvider.auth.addApiKey', { ns: 'common' })
return { text, variant: 'secondary' as const }
}
function ModelAuthDropdown({
provider,
state,
isChangingPriority,
onChangePriority,
}: ModelAuthDropdownProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleClose = useCallback(() => setOpen(false), [])
const buttonConfig = getButtonConfig(state.variant, state.hasCredentials, t)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button
className="flex grow"
size="small"
variant={buttonConfig.variant}
title={buttonConfig.text}
>
<span className="i-ri-equalizer-2-line mr-1 h-3.5 w-3.5 shrink-0" />
<span className="w-0 grow truncate text-left">
{buttonConfig.text}
</span>
</Button>
)}
/>
<PopoverContent placement="bottom-end">
<DropdownContent
provider={provider}
state={state}
isChangingPriority={isChangingPriority}
onChangePriority={onChangePriority}
onClose={handleClose}
/>
</PopoverContent>
</Popover>
)
}
export default memo(ModelAuthDropdown)

View File

@@ -1,66 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { PreferredProviderTypeEnum } from '../../declarations'
import UsagePrioritySection from './usage-priority-section'
describe('UsagePrioritySection', () => {
const onSelect = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering
describe('Rendering', () => {
it('should render title and both option buttons', () => {
render(<UsagePrioritySection value="credits" onSelect={onSelect} />)
expect(screen.getByText(/usagePriority/)).toBeInTheDocument()
expect(screen.getAllByRole('button')).toHaveLength(2)
})
})
// Selection state
describe('Selection state', () => {
it('should highlight AI credits option when value is credits', () => {
render(<UsagePrioritySection value="credits" onSelect={onSelect} />)
const buttons = screen.getAllByRole('button')
expect(buttons[0].className).toContain('border-components-option-card-option-selected-border')
expect(buttons[1].className).not.toContain('border-components-option-card-option-selected-border')
})
it('should highlight API key option when value is apiKey', () => {
render(<UsagePrioritySection value="apiKey" onSelect={onSelect} />)
const buttons = screen.getAllByRole('button')
expect(buttons[0].className).not.toContain('border-components-option-card-option-selected-border')
expect(buttons[1].className).toContain('border-components-option-card-option-selected-border')
})
it('should highlight API key option when value is apiKeyOnly', () => {
render(<UsagePrioritySection value="apiKeyOnly" onSelect={onSelect} />)
const buttons = screen.getAllByRole('button')
expect(buttons[1].className).toContain('border-components-option-card-option-selected-border')
})
})
// User interactions
describe('User interactions', () => {
it('should call onSelect with system when clicking AI credits option', () => {
render(<UsagePrioritySection value="apiKey" onSelect={onSelect} />)
fireEvent.click(screen.getAllByRole('button')[0])
expect(onSelect).toHaveBeenCalledWith(PreferredProviderTypeEnum.system)
})
it('should call onSelect with custom when clicking API key option', () => {
render(<UsagePrioritySection value="credits" onSelect={onSelect} />)
fireEvent.click(screen.getAllByRole('button')[1])
expect(onSelect).toHaveBeenCalledWith(PreferredProviderTypeEnum.custom)
})
})
})

View File

@@ -1,70 +0,0 @@
import type { UsagePriority } from '../use-credential-panel-state'
import { useTranslation } from 'react-i18next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { cn } from '@/utils/classnames'
import { PreferredProviderTypeEnum } from '../../declarations'
type UsagePrioritySectionProps = {
value: UsagePriority
disabled?: boolean
onSelect: (key: PreferredProviderTypeEnum) => void
}
const options = [
{ key: PreferredProviderTypeEnum.system, labelKey: 'modelProvider.card.aiCreditsOption' },
{ key: PreferredProviderTypeEnum.custom, labelKey: 'modelProvider.card.apiKeyOption' },
] as const
export default function UsagePrioritySection({ value, disabled, onSelect }: UsagePrioritySectionProps) {
const { t } = useTranslation()
const selectedKey = value === 'credits'
? PreferredProviderTypeEnum.system
: PreferredProviderTypeEnum.custom
return (
<div className="p-1">
<div className="flex items-center gap-1 rounded-lg p-1">
<div className="shrink-0 px-0.5 py-1">
<span className="i-ri-arrow-up-double-line block h-4 w-4 text-text-tertiary" />
</div>
<div className="flex min-w-0 flex-1 items-center gap-0.5 py-0.5">
<span className="truncate text-text-secondary system-sm-medium">
{t('modelProvider.card.usagePriority', { ns: 'common' })}
</span>
<Tooltip>
<TooltipTrigger
aria-label={t('modelProvider.card.usagePriorityTip', { ns: 'common' })}
delay={0}
render={(
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
{t('modelProvider.card.usagePriorityTip', { ns: 'common' })}
</TooltipContent>
</Tooltip>
</div>
<div className="flex shrink-0 items-center gap-1">
{options.map(option => (
<button
key={option.key}
type="button"
className={cn(
'shrink-0 whitespace-nowrap rounded-md px-2 py-1 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-button-primary-border disabled:opacity-50',
selectedKey === option.key
? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-panel-bg text-text-primary shadow-xs system-xs-medium'
: 'border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary system-xs-regular hover:bg-components-option-card-option-bg-hover',
)}
disabled={disabled}
onClick={() => onSelect(option.key)}
>
{t(option.labelKey, { ns: 'common' })}
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -1,52 +0,0 @@
import type { Credential, ModelProvider } from '../../declarations'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { useActiveProviderCredential } from '@/service/use-models'
import {
useUpdateModelList,
useUpdateModelProviders,
} from '../../hooks'
export function useActivateCredential(provider: ModelProvider) {
const { t } = useTranslation()
const updateModelProviders = useUpdateModelProviders()
const updateModelList = useUpdateModelList()
const { mutate, isPending } = useActiveProviderCredential(provider.provider)
const [optimisticId, setOptimisticId] = useState<string>()
const currentId = provider.custom_configuration.current_credential_id
const selectedCredentialId = optimisticId ?? currentId
const selectedIdRef = useRef(selectedCredentialId)
selectedIdRef.current = selectedCredentialId
const supportedModelTypesRef = useRef(provider.supported_model_types)
supportedModelTypesRef.current = provider.supported_model_types
const activate = useCallback((credential: Credential) => {
if (credential.credential_id === selectedIdRef.current)
return
setOptimisticId(credential.credential_id)
mutate(
{ credential_id: credential.credential_id },
{
onSuccess: () => {
Toast.notify({ type: 'success', message: t('api.actionSuccess', { ns: 'common' }) })
updateModelProviders()
supportedModelTypesRef.current.forEach(type => updateModelList(type))
},
onError: () => {
setOptimisticId(undefined)
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
},
)
}, [mutate, t, updateModelProviders, updateModelList])
return {
selectedCredentialId,
isActivating: isPending,
activate,
}
}

View File

@@ -1,17 +1,9 @@
import type { ModelItem, ModelProvider } from '../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { disableModel, enableModel } from '@/service/common'
import { ModelStatusEnum } from '../declarations'
import ModelListItem from './model-list-item'
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
let mockModelLoadBalancingEnabled = false
vi.mock('@/context/app-context', () => ({
@@ -77,7 +69,6 @@ describe('ModelListItem', () => {
provider={mockProvider}
isConfigurable={false}
/>,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
expect(screen.getByTestId('model-name')).toBeInTheDocument()
@@ -92,7 +83,6 @@ describe('ModelListItem', () => {
isConfigurable={false}
onChange={onChange}
/>,
{ wrapper: createWrapper() },
)
fireEvent.click(screen.getByRole('switch'))
@@ -112,7 +102,6 @@ describe('ModelListItem', () => {
isConfigurable={false}
onChange={onChange}
/>,
{ wrapper: createWrapper() },
)
fireEvent.click(screen.getByRole('switch'))
@@ -133,7 +122,6 @@ describe('ModelListItem', () => {
isConfigurable={false}
onModifyLoadBalancing={onModifyLoadBalancing}
/>,
{ wrapper: createWrapper() },
)
fireEvent.click(screen.getByRole('button', { name: 'modify load balancing' }))

View File

@@ -1,5 +1,4 @@
import type { ModelItem, ModelProvider } from '../declarations'
import { useQueryClient } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@@ -10,7 +9,6 @@ import Tooltip from '@/app/components/base/tooltip'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { useProviderContext, useProviderContextSelector } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import { disableModel, enableModel } from '@/service/common'
import { cn } from '@/utils/classnames'
import { ModelStatusEnum } from '../declarations'
@@ -32,30 +30,16 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad
const { plan } = useProviderContext()
const modelLoadBalancingEnabled = useProviderContextSelector(state => state.modelLoadBalancingEnabled)
const { isCurrentWorkspaceManager } = useAppContext()
const queryClient = useQueryClient()
const updateModelList = useUpdateModelList()
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: provider.provider,
},
},
})
const toggleModelEnablingStatus = useCallback(async (enabled: boolean) => {
if (enabled)
await enableModel(`/workspaces/current/model-providers/${provider.provider}/models/enable`, { model: model.model, model_type: model.model_type })
else
await disableModel(`/workspaces/current/model-providers/${provider.provider}/models/disable`, { model: model.model, model_type: model.model_type })
queryClient.invalidateQueries({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'none',
})
updateModelList(model.model_type)
onChange?.(provider.provider)
}, [model.model, model.model_type, modelProviderModelListQueryKey, onChange, provider.provider, queryClient, updateModelList])
}, [model.model, model.model_type, onChange, provider.provider, updateModelList])
const { run: debouncedToggleModelEnablingStatus } = useDebounceFn(toggleModelEnablingStatus, { wait: 500 })
@@ -74,7 +58,7 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad
modelName={model.model}
/>
<ModelName
className="grow text-text-secondary system-md-regular"
className="system-md-regular grow text-text-secondary"
modelItem={model}
showModelType
showMode

View File

@@ -250,7 +250,7 @@ const ModelLoadBalancingModal = ({
modelName={model!.model}
/>
<ModelName
className="grow text-text-secondary system-md-regular"
className="system-md-regular grow text-text-secondary"
modelItem={model!}
showModelType
showMode

View File

@@ -1,137 +0,0 @@
import type { FC } from 'react'
import type { PluginDetail } from '@/app/components/plugins/types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { HeaderModals } from '@/app/components/plugins/plugin-detail-panel/detail-header/components'
import { useDetailHeaderState, usePluginOperations } from '@/app/components/plugins/plugin-detail-panel/detail-header/hooks'
import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown'
import { PluginSource } from '@/app/components/plugins/types'
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
import { useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { cn } from '@/utils/classnames'
import { getMarketplaceUrl } from '@/utils/var'
type Props = {
detail: PluginDetail
onUpdate?: () => void
}
const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
const { t } = useTranslation()
const { theme } = useTheme()
const locale = useLocale()
const { source, version, latest_version, latest_unique_identifier, meta } = detail
const author = detail.declaration?.author ?? ''
const name = detail.declaration?.name ?? detail.name
const {
modalStates,
versionPicker,
hasNewVersion,
isAutoUpgradeEnabled,
isFromMarketplace,
isFromGitHub,
} = useDetailHeaderState(detail)
const {
handleUpdate,
handleUpdatedFromMarketplace,
handleDelete,
} = usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace,
onUpdate,
})
const handleVersionSelect = (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => {
versionPicker.setTargetVersion(state)
handleUpdate(state.isDowngrade)
}
const handleTriggerLatestUpdate = () => {
if (isFromMarketplace) {
versionPicker.setTargetVersion({
version: latest_version,
unique_identifier: latest_unique_identifier,
})
}
handleUpdate()
}
const detailUrl = useMemo(() => {
if (source === PluginSource.github)
return meta?.repo ? `https://github.com/${meta.repo}` : ''
if (source === PluginSource.marketplace)
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: locale, theme })
return ''
}, [source, meta?.repo, author, name, locale, theme])
return (
<>
{!!version && (
<PluginVersionPicker
disabled={!isFromMarketplace}
isShow={versionPicker.isShow}
onShowChange={versionPicker.setIsShow}
pluginID={detail.plugin_id}
currentVersion={version}
onSelect={handleVersionSelect}
sideOffset={4}
alignOffset={0}
trigger={(
<span
className={cn(
'relative inline-flex min-w-5 items-center justify-center gap-[3px] rounded-md border border-divider-deep bg-state-base-hover px-[5px] py-[2px] text-text-tertiary system-xs-medium-uppercase',
isFromMarketplace && 'cursor-pointer hover:bg-state-base-hover-alt',
)}
>
<span>{version}</span>
{isFromMarketplace && <span aria-hidden className="i-ri-arrow-left-right-line h-3 w-3" />}
{hasNewVersion && (
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 rounded-full bg-state-destructive-solid" />
)}
</span>
)}
/>
)}
{(hasNewVersion || isFromGitHub) && (
<Button
variant="secondary-accent"
size="small"
className="!h-5"
onClick={handleTriggerLatestUpdate}
>
{t('detailPanel.operation.update', { ns: 'plugin' })}
</Button>
)}
<OperationDropdown
source={source}
onInfo={modalStates.showPluginInfo}
onCheckVersion={() => handleUpdate()}
onRemove={modalStates.showDeleteConfirm}
detailUrl={detailUrl}
placement="bottom-start"
popupClassName="w-[192px]"
/>
<HeaderModals
detail={detail}
modalStates={modalStates}
targetVersion={versionPicker.targetVersion}
isDowngrade={versionPicker.isDowngrade}
isAutoUpgradeEnabled={isAutoUpgradeEnabled}
onUpdatedFromMarketplace={handleUpdatedFromMarketplace}
onDelete={handleDelete}
/>
</>
)
}
export default ProviderCardActions

View File

@@ -1,8 +0,0 @@
.gridBg {
background-size: 4px 4px;
background-image:
linear-gradient(to right, var(--color-divider-subtle) 0.5px, transparent 0.5px),
linear-gradient(to bottom, var(--color-divider-subtle) 0.5px, transparent 0.5px);
-webkit-mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6), transparent 70%);
mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6), transparent 70%);
}

View File

@@ -2,16 +2,11 @@ import type { ModelProvider } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import QuotaPanel from './quota-panel'
let mockWorkspaceData: {
trial_credits: number
trial_credits_used: number
next_credit_reset_date: string
} | undefined = {
let mockWorkspace = {
trial_credits: 100,
trial_credits_used: 30,
next_credit_reset_date: '2024-12-31',
}
let mockWorkspaceIsPending = false
let mockTrialModels: string[] = ['langgenius/openai/openai']
let mockPlugins = [{
plugin_id: 'langgenius/openai',
@@ -30,16 +25,15 @@ vi.mock('@/app/components/base/icons/src/public/llm', () => {
}
})
vi.mock('@/service/use-common', () => ({
useCurrentWorkspace: () => ({
data: mockWorkspaceData,
isPending: mockWorkspaceIsPending,
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: mockWorkspace,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({
data: {
useGlobalPublicStore: (selector: (state: { systemFeatures: { trial_models: string[] } }) => unknown) => selector({
systemFeatures: {
trial_models: mockTrialModels,
},
}),
@@ -77,21 +71,22 @@ describe('QuotaPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkspaceData = {
mockWorkspace = {
trial_credits: 100,
trial_credits_used: 30,
next_credit_reset_date: '2024-12-31',
}
mockWorkspaceIsPending = false
mockTrialModels = ['langgenius/openai/openai']
mockPlugins = [{ plugin_id: 'langgenius/openai', latest_package_identifier: 'openai@1.0.0' }]
})
it('should render loading state', () => {
mockWorkspaceData = undefined
mockWorkspaceIsPending = true
render(<QuotaPanel providers={mockProviders} />)
render(
<QuotaPanel
providers={mockProviders}
isLoading
/>,
)
expect(screen.getByRole('status')).toBeInTheDocument()
})
@@ -107,17 +102,8 @@ describe('QuotaPanel', () => {
expect(screen.getByText(/modelProvider\.resetDate/)).toBeInTheDocument()
})
it('should keep quota content during background refetch when cached workspace exists', () => {
mockWorkspaceIsPending = true
render(<QuotaPanel providers={mockProviders} />)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText('70')).toBeInTheDocument()
})
it('should floor credits at zero when usage is higher than quota', () => {
mockWorkspaceData = {
mockWorkspace = {
trial_credits: 10,
trial_credits_used: 999,
next_credit_reset_date: '',
@@ -125,7 +111,7 @@ describe('QuotaPanel', () => {
render(<QuotaPanel providers={mockProviders} />)
expect(screen.getByText(/modelProvider\.card\.quotaExhausted/)).toBeInTheDocument()
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.queryByText(/modelProvider\.resetDate/)).not.toBeInTheDocument()
})

View File

@@ -1,39 +1,63 @@
import type { FC } from 'react'
import type { ComponentType, FC } from 'react'
import type { ModelProvider } from '../declarations'
import type { Plugin } from '@/app/components/plugins/types'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AnthropicShortLight, Deepseek, Gemini, Grok, OpenaiSmall, Tongyi } from '@/app/components/base/icons/src/public/llm'
import Loading from '@/app/components/base/loading'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { useSystemFeaturesQuery } from '@/context/global-public-context'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useTimestamp from '@/hooks/use-timestamp'
import { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { cn } from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
import { PreferredProviderTypeEnum } from '../declarations'
import { useMarketplaceAllPlugins } from '../hooks'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils'
import styles from './quota-panel.module.css'
import { useTrialCredits } from './use-trial-credits'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap } from '../utils'
// Icon map for each provider - single source of truth for provider icons
const providerIconMap: Record<ModelProviderQuotaGetPaid, ComponentType<{ className?: string }>> = {
[ModelProviderQuotaGetPaid.OPENAI]: OpenaiSmall,
[ModelProviderQuotaGetPaid.ANTHROPIC]: AnthropicShortLight,
[ModelProviderQuotaGetPaid.GEMINI]: Gemini,
[ModelProviderQuotaGetPaid.X]: Grok,
[ModelProviderQuotaGetPaid.DEEPSEEK]: Deepseek,
[ModelProviderQuotaGetPaid.TONGYI]: Tongyi,
}
// Derive allProviders from the shared constant
const allProviders = MODEL_PROVIDER_QUOTA_GET_PAID.map(key => ({
key,
Icon: providerIconMap[key],
}))
// Map provider key to plugin ID
// provider key format: langgenius/provider/model, plugin ID format: langgenius/provider
const providerKeyToPluginId: Record<ModelProviderQuotaGetPaid, string> = {
[ModelProviderQuotaGetPaid.OPENAI]: 'langgenius/openai',
[ModelProviderQuotaGetPaid.ANTHROPIC]: 'langgenius/anthropic',
[ModelProviderQuotaGetPaid.GEMINI]: 'langgenius/gemini',
[ModelProviderQuotaGetPaid.X]: 'langgenius/x',
[ModelProviderQuotaGetPaid.DEEPSEEK]: 'langgenius/deepseek',
[ModelProviderQuotaGetPaid.TONGYI]: 'langgenius/tongyi',
}
type QuotaPanelProps = {
providers: ModelProvider[]
isLoading?: boolean
}
const QuotaPanel: FC<QuotaPanelProps> = ({
providers,
isLoading = false,
}) => {
const { t } = useTranslation()
const { credits, isExhausted, isLoading, nextCreditResetDate } = useTrialCredits()
const { data: systemFeatures } = useSystemFeaturesQuery()
const trialModels = systemFeatures?.trial_models ?? []
const { currentWorkspace } = useAppContext()
const { trial_models } = useGlobalPublicStore(s => s.systemFeatures)
const credits = Math.max((currentWorkspace.trial_credits - currentWorkspace.trial_credits_used) || 0, 0)
const providerMap = useMemo(() => new Map(
providers.map(p => [p.provider, p.preferred_provider_type]),
), [providers])
@@ -74,11 +98,6 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
}
}, [providers, isShowInstallModal, hideInstallFromMarketplace])
const tipText = t('modelProvider.card.tip', {
ns: 'common',
modelNames: trialModels.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', '),
})
if (isLoading) {
return (
<div className="my-2 flex min-h-[72px] items-center justify-center rounded-xl border-[0.5px] border-components-panel-border bg-third-party-model-bg-default shadow-xs">
@@ -88,88 +107,59 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
}
return (
<div className={cn(
'relative my-2 min-w-[72px] shrink-0 overflow-hidden rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs',
isExhausted
? 'border-state-destructive-border hover:bg-state-destructive-hover'
: 'border-components-panel-border bg-third-party-model-bg-default',
)}
>
<div className={cn('pointer-events-none absolute inset-0', styles.gridBg)} />
<div className="relative">
<div className="mb-2 flex h-4 items-center text-text-tertiary system-xs-medium-uppercase">
{t('modelProvider.quota', { ns: 'common' })}
<Tooltip>
<TooltipTrigger
aria-label={tipText}
delay={0}
render={(
<span className="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
{tipText}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-text-tertiary">
{credits > 0
? <span className="mr-0.5 text-text-secondary system-xl-semibold">{formatNumber(credits)}</span>
: <span className="mr-0.5 text-text-destructive system-xl-semibold">{t('modelProvider.card.quotaExhausted', { ns: 'common' })}</span>}
{nextCreditResetDate
? (
<>
<span>·</span>
<span>
{t('modelProvider.resetDate', {
ns: 'common',
date: formatTime(nextCreditResetDate, t('dateFormat', { ns: 'appLog' })),
interpolation: { escapeValue: false },
})}
</span>
</>
)
: null}
</div>
<div className="flex items-center gap-1">
{allProviders.filter(({ key }) => trialModels.includes(key)).map(({ key, Icon }) => {
const providerType = providerMap.get(key)
const isConfigured = (installedProvidersMap.get(key)?.length ?? 0) > 0
const getTooltipKey = () => {
if (!providerType)
return 'modelProvider.card.modelNotSupported'
if (isConfigured && providerType === PreferredProviderTypeEnum.custom)
return 'modelProvider.card.modelAPI'
return 'modelProvider.card.modelSupported'
}
const tooltipText = t(getTooltipKey(), { modelName: modelNameMap[key], ns: 'common' })
return (
<Tooltip key={key}>
<TooltipTrigger
aria-label={tooltipText}
delay={0}
render={(
<div
className={cn('relative h-6 w-6', !providerType && 'cursor-pointer hover:opacity-80')}
onClick={() => handleIconClick(key)}
>
<Icon className="h-6 w-6 rounded-lg" />
{!providerType && (
<div className="absolute inset-0 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge opacity-30" />
)}
</div>
)}
/>
<TooltipContent>
{tooltipText}
</TooltipContent>
</Tooltip>
<div className={cn('my-2 min-w-[72px] shrink-0 rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs', credits <= 0 ? 'border-state-destructive-border hover:bg-state-destructive-hover' : 'border-components-panel-border bg-third-party-model-bg-default')}>
<div className="system-xs-medium-uppercase mb-2 flex h-4 items-center text-text-tertiary">
{t('modelProvider.quota', { ns: 'common' })}
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common', modelNames: trial_models.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', ') })} />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-text-tertiary">
<span className="system-md-semibold-uppercase mr-0.5 text-text-secondary">{formatNumber(credits)}</span>
<span>{t('modelProvider.credits', { ns: 'common' })}</span>
{currentWorkspace.next_credit_reset_date
? (
<>
<span>·</span>
<span>
{t('modelProvider.resetDate', {
ns: 'common',
date: formatTime(currentWorkspace.next_credit_reset_date, t('dateFormat', { ns: 'appLog' })),
interpolation: { escapeValue: false },
})}
</span>
</>
)
})}
</div>
: null}
</div>
<div className="flex items-center gap-1">
{allProviders.filter(({ key }) => trial_models.includes(key)).map(({ key, Icon }) => {
const providerType = providerMap.get(key)
const isConfigured = (installedProvidersMap.get(key)?.length ?? 0) > 0 // means the provider is configured API key
const getTooltipKey = () => {
// if provider type is not set, it means the provider is not installed
if (!providerType)
return 'modelProvider.card.modelNotSupported'
if (isConfigured && providerType === PreferredProviderTypeEnum.custom)
return 'modelProvider.card.modelAPI'
return 'modelProvider.card.modelSupported'
}
return (
<Tooltip
key={key}
popupContent={t(getTooltipKey(), { modelName: modelNameMap[key], ns: 'common' })}
>
<div
className={cn('relative h-6 w-6', !providerType && 'cursor-pointer hover:opacity-80')}
onClick={() => handleIconClick(key)}
>
<Icon className="h-6 w-6 rounded-lg" />
{!providerType && (
<div className="absolute inset-0 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge opacity-30" />
)}
</div>
</Tooltip>
)
})}
</div>
</div>
{isShowInstallModal && selectedPlugin && (

View File

@@ -1,89 +0,0 @@
import { render, screen } from '@testing-library/react'
import SystemQuotaCard from './system-quota-card'
describe('SystemQuotaCard', () => {
// Renders container with children
describe('Rendering', () => {
it('should render children', () => {
render(
<SystemQuotaCard>
<span>content</span>
</SystemQuotaCard>,
)
expect(screen.getByText('content')).toBeInTheDocument()
})
it('should apply default variant styles', () => {
const { container } = render(
<SystemQuotaCard>
<span>test</span>
</SystemQuotaCard>,
)
const card = container.firstElementChild!
expect(card.className).toContain('bg-white')
})
it('should apply destructive variant styles', () => {
const { container } = render(
<SystemQuotaCard variant="destructive">
<span>test</span>
</SystemQuotaCard>,
)
const card = container.firstElementChild!
expect(card.className).toContain('border-state-destructive-border')
})
})
// Label sub-component
describe('Label', () => {
it('should apply default variant text color when no className provided', () => {
render(
<SystemQuotaCard>
<SystemQuotaCard.Label>Default label</SystemQuotaCard.Label>
</SystemQuotaCard>,
)
expect(screen.getByText('Default label').className).toContain('text-text-secondary')
})
it('should apply destructive variant text color when no className provided', () => {
render(
<SystemQuotaCard variant="destructive">
<SystemQuotaCard.Label>Error label</SystemQuotaCard.Label>
</SystemQuotaCard>,
)
expect(screen.getByText('Error label').className).toContain('text-text-destructive')
})
it('should override variant color with custom className', () => {
render(
<SystemQuotaCard variant="destructive">
<SystemQuotaCard.Label className="gap-1">Custom label</SystemQuotaCard.Label>
</SystemQuotaCard>,
)
const label = screen.getByText('Custom label')
expect(label.className).toContain('gap-1')
expect(label.className).not.toContain('text-text-destructive')
})
})
// Actions sub-component
describe('Actions', () => {
it('should render action children', () => {
render(
<SystemQuotaCard>
<SystemQuotaCard.Actions>
<button>Click me</button>
</SystemQuotaCard.Actions>
</SystemQuotaCard>,
)
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument()
})
})
})

View File

@@ -1,67 +0,0 @@
import type { ReactNode } from 'react'
import { createContext, useContext } from 'react'
import { cn } from '@/utils/classnames'
import styles from './quota-panel.module.css'
type Variant = 'default' | 'destructive'
const VariantContext = createContext<Variant>('default')
const containerVariants: Record<Variant, string> = {
default: 'border-components-panel-border bg-white/[0.18]',
destructive: 'border-state-destructive-border bg-state-destructive-hover',
}
const labelVariants: Record<Variant, string> = {
default: 'text-text-secondary',
destructive: 'text-text-destructive',
}
type SystemQuotaCardProps = {
variant?: Variant
children: ReactNode
}
const SystemQuotaCard = ({
variant = 'default',
children,
}: SystemQuotaCardProps) => {
return (
<VariantContext.Provider value={variant}>
<div className={cn(
'relative isolate ml-1 flex w-[128px] shrink-0 flex-col justify-between rounded-lg border-[0.5px] p-1 shadow-xs',
containerVariants[variant],
)}
>
<div className={cn('pointer-events-none absolute inset-0 rounded-[7px]', styles.gridBg)} />
{children}
</div>
</VariantContext.Provider>
)
}
const Label = ({ children, className }: { children: ReactNode, className?: string }) => {
const variant = useContext(VariantContext)
return (
<div className={cn(
'relative z-[1] flex items-center gap-1 truncate px-1.5 pt-1 system-xs-medium',
className ?? labelVariants[variant],
)}
>
{children}
</div>
)
}
const Actions = ({ children }: { children: ReactNode }) => {
return (
<div className="relative z-[1] flex items-center gap-0.5">
{children}
</div>
)
}
SystemQuotaCard.Label = Label
SystemQuotaCard.Actions = Actions
export default SystemQuotaCard

View File

@@ -1,235 +0,0 @@
import type { ModelProvider } from '../declarations'
import { renderHook } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
} from '../declarations'
import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state'
const mockTrialCredits = { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false, nextCreditResetDate: undefined }
vi.mock('./use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return { ...actual, IS_CLOUD_EDITION: true }
})
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test-provider',
provider_credential_schema: { credential_form_schemas: [] },
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: 'My Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
supported_model_types: ['llm'],
...overrides,
} as unknown as ModelProvider)
describe('useCredentialPanelState', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.assign(mockTrialCredits, { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false })
})
// Credits priority variants
describe('Credits priority variants', () => {
it('should return credits-active when credits available', () => {
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
expect(result.current.variant).toBe('credits-active')
expect(result.current.priority).toBe('credits')
expect(result.current.supportsCredits).toBe(true)
})
it('should return api-fallback when credits exhausted but API key authorized', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
expect(result.current.variant).toBe('api-fallback')
})
it('should return no-usage when credits exhausted and API key unauthorized', () => {
mockTrialCredits.isExhausted = true
const provider = createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('no-usage')
})
it('should return credits-exhausted when credits exhausted and no credentials', () => {
mockTrialCredits.isExhausted = true
const provider = createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('credits-exhausted')
})
})
// API key priority variants
describe('API key priority variants', () => {
it('should return api-active when API key authorized', () => {
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('api-active')
expect(result.current.priority).toBe('apiKey')
})
it('should return credits-fallback when API key unauthorized and credits available', () => {
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('credits-fallback')
})
it('should return credits-fallback when no credentials and credits available', () => {
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('credits-fallback')
})
it('should return no-usage when no credentials and credits exhausted', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('no-usage')
})
it('should return api-unavailable when credential with name unauthorized and credits exhausted', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: 'Bad Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('api-unavailable')
})
})
// apiKeyOnly priority
describe('apiKeyOnly priority (non-cloud / system disabled)', () => {
it('should return apiKeyOnly when system config disabled', () => {
const provider = createProvider({
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.priority).toBe('apiKeyOnly')
expect(result.current.supportsCredits).toBe(false)
})
})
// Derived metadata
describe('Derived metadata', () => {
it('should show priority switcher when credits supported and custom config active', () => {
const provider = createProvider()
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.showPrioritySwitcher).toBe(true)
})
it('should hide priority switcher when system config disabled', () => {
const provider = createProvider({
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.showPrioritySwitcher).toBe(false)
})
it('should expose credential name from provider', () => {
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
expect(result.current.credentialName).toBe('My Key')
})
it('should expose credits amount', () => {
mockTrialCredits.credits = 500
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
expect(result.current.credits).toBe(500)
})
})
})
describe('isDestructiveVariant', () => {
it.each([
['credits-exhausted', true],
['no-usage', true],
['api-unavailable', true],
['credits-active', false],
['api-fallback', false],
['api-active', false],
['api-required-add', false],
['api-required-configure', false],
] as const)('should return %s for variant %s', (variant, expected) => {
expect(isDestructiveVariant(variant)).toBe(expected)
})
})

View File

@@ -1,106 +0,0 @@
import type { ModelProvider } from '../declarations'
import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
import { IS_CLOUD_EDITION } from '@/config'
import {
PreferredProviderTypeEnum,
} from '../declarations'
import { useTrialCredits } from './use-trial-credits'
export type UsagePriority = 'credits' | 'apiKey' | 'apiKeyOnly'
export type CardVariant
= | 'credits-active'
| 'credits-fallback'
| 'credits-exhausted'
| 'no-usage'
| 'api-fallback'
| 'api-active'
| 'api-required-add'
| 'api-required-configure'
| 'api-unavailable'
export type CredentialPanelState = {
variant: CardVariant
priority: UsagePriority
supportsCredits: boolean
showPrioritySwitcher: boolean
hasCredentials: boolean
isCreditsExhausted: boolean
credentialName: string | undefined
credits: number
}
const DESTRUCTIVE_VARIANTS = new Set<CardVariant>([
'credits-exhausted',
'no-usage',
'api-unavailable',
])
export const isDestructiveVariant = (variant: CardVariant) =>
DESTRUCTIVE_VARIANTS.has(variant)
function deriveVariant(
priority: UsagePriority,
isExhausted: boolean,
hasCredential: boolean,
authorized: boolean | undefined,
credentialName: string | undefined,
): CardVariant {
if (priority === 'credits') {
if (!isExhausted)
return 'credits-active'
if (hasCredential && authorized)
return 'api-fallback'
if (hasCredential && !authorized)
return 'no-usage'
return 'credits-exhausted'
}
if (hasCredential && authorized)
return 'api-active'
if (priority === 'apiKey' && !isExhausted)
return 'credits-fallback'
if (priority === 'apiKey' && !hasCredential)
return 'no-usage'
if (hasCredential && !authorized)
return credentialName ? 'api-unavailable' : 'api-required-configure'
return 'api-required-add'
}
export function useCredentialPanelState(provider: ModelProvider): CredentialPanelState {
const { isExhausted, credits } = useTrialCredits()
const {
hasCredential,
authorized,
current_credential_name,
} = useCredentialStatus(provider)
const systemConfig = provider.system_configuration
const preferredType = provider.preferred_provider_type
const supportsCredits = systemConfig.enabled && IS_CLOUD_EDITION
const priority: UsagePriority = !supportsCredits
? 'apiKeyOnly'
: preferredType === PreferredProviderTypeEnum.system
? 'credits'
: 'apiKey'
const showPrioritySwitcher = supportsCredits
const variant = deriveVariant(priority, isExhausted, hasCredential, !!authorized, current_credential_name)
return {
variant,
priority,
supportsCredits,
showPrioritySwitcher,
hasCredentials: hasCredential,
isCreditsExhausted: isExhausted,
credentialName: current_credential_name,
credits,
}
}

View File

@@ -1,15 +0,0 @@
import { useCurrentWorkspace } from '@/service/use-common'
export const useTrialCredits = () => {
const { data: currentWorkspace, isPending } = useCurrentWorkspace()
const totalCredits = currentWorkspace?.trial_credits ?? 0
const credits = Math.max(totalCredits - (currentWorkspace?.trial_credits_used ?? 0), 0)
return {
credits,
totalCredits,
isExhausted: credits <= 0,
isLoading: isPending && !currentWorkspace,
nextCreditResetDate: currentWorkspace?.next_credit_reset_date,
}
}

View File

@@ -21,7 +21,7 @@ const ProviderIcon: FC<ProviderIconProps> = ({
if (provider.provider === 'langgenius/anthropic/anthropic') {
return (
<div className={cn('py-[7px]', className)}>
<div className="mb-2 py-[7px]">
{theme === Theme.dark && <AnthropicLight className="h-2.5 w-[90px]" />}
{theme === Theme.light && <AnthropicDark className="h-2.5 w-[90px]" />}
</div>
@@ -30,7 +30,7 @@ const ProviderIcon: FC<ProviderIconProps> = ({
if (provider.provider === 'langgenius/openai/openai') {
return (
<div className={className}>
<div className="mb-2">
<Openai className="h-6 w-auto text-text-inverted-dimmed" />
</div>
)
@@ -48,7 +48,7 @@ const ProviderIcon: FC<ProviderIconProps> = ({
)}
className="h-6 w-6"
/>
<div className="text-text-primary system-md-semibold">
<div className="system-md-semibold text-text-primary">
{renderI18nObject(provider.label, language)}
</div>
</div>

View File

@@ -26,7 +26,6 @@ vi.mock('react-i18next', async () => {
const mockNotify = vi.hoisted(() => vi.fn())
const mockUpdateModelList = vi.hoisted(() => vi.fn())
const mockInvalidateDefaultModel = vi.hoisted(() => vi.fn())
const mockUpdateDefaultModel = vi.hoisted(() => vi.fn(() => Promise.resolve({ result: 'success' })))
let mockIsCurrentWorkspaceManager = true
@@ -58,7 +57,6 @@ vi.mock('../hooks', () => ({
vi.fn(),
],
useUpdateModelList: () => mockUpdateModelList,
useInvalidateDefaultModel: () => mockInvalidateDefaultModel,
}))
vi.mock('@/service/common', () => ({
@@ -101,7 +99,7 @@ describe('SystemModel', () => {
expect(screen.getByRole('button', { name: /system model settings/i })).toBeInTheDocument()
})
it('should open dialog when button is clicked', async () => {
it('should open modal when button is clicked', async () => {
render(<SystemModel {...defaultProps} />)
const button = screen.getByRole('button', { name: /system model settings/i })
fireEvent.click(button)
@@ -115,7 +113,7 @@ describe('SystemModel', () => {
expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled()
})
it('should close dialog when cancel is clicked', async () => {
it('should close modal when cancel is clicked', async () => {
render(<SystemModel {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
@@ -146,7 +144,6 @@ describe('SystemModel', () => {
type: 'success',
message: 'Modified successfully',
})
expect(mockInvalidateDefaultModel).toHaveBeenCalledTimes(5)
expect(mockUpdateModelList).toHaveBeenCalledTimes(5)
})
})

Some files were not shown because too many files have changed in this diff Show More