mirror of
https://github.com/langgenius/dify.git
synced 2026-03-10 09:45:11 +00:00
Compare commits
2 Commits
feat/model
...
3-10-updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b22bc2c78 | ||
|
|
039875ac14 |
1
.github/dependabot.yml
vendored
1
.github/dependabot.yml
vendored
@@ -25,7 +25,6 @@ updates:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 2
|
||||
ignore:
|
||||
- dependency-name: "ky"
|
||||
- dependency-name: "tailwind-merge"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "tailwindcss"
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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={(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -3,10 +3,8 @@ import type { FormValue } from '@/app/components/header/account-setting/model-pr
|
||||
import type { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import type { AppModeEnum, CompletionParams, Model, ModelModeType } from '@/types/app'
|
||||
import {
|
||||
useBoolean,
|
||||
useSessionStorageState,
|
||||
} from 'ahooks'
|
||||
import { useSessionStorageState } from 'ahooks'
|
||||
import useBoolean from 'ahooks/lib/useBoolean'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -226,7 +224,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[0px]">
|
||||
<div className="mb-1.5 text-text-secondary system-sm-semibold-uppercase">{t('codegen.instruction', { ns: 'appDebug' })}</div>
|
||||
<div className="system-sm-semibold-uppercase mb-1.5 text-text-secondary">{t('codegen.instruction', { ns: 'appDebug' })}</div>
|
||||
<InstructionEditor
|
||||
editorKey={editorKey}
|
||||
value={instruction}
|
||||
@@ -250,7 +248,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Generator className="h-4 w-4" />
|
||||
<span className="text-xs font-semibold">{t('codegen.generate', { ns: 'appDebug' })}</span>
|
||||
<span className="text-xs font-semibold ">{t('codegen.generate', { ns: 'appDebug' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')) : '') || ''
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useReactFlow, useStoreApi } from 'reactflow'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel'
|
||||
import {
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
UPDATE_WORKFLOW_NODES_MAP,
|
||||
} from './index'
|
||||
import { WorkflowVariableBlockNode } from './node'
|
||||
import { useLlmModelPluginInstalled } from './use-llm-model-plugin-installed'
|
||||
|
||||
type WorkflowVariableBlockComponentProps = {
|
||||
nodeKey: string
|
||||
@@ -69,8 +68,6 @@ const WorkflowVariableBlockComponent = ({
|
||||
const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]]
|
||||
|
||||
const isException = isExceptionVariable(varName, node?.type)
|
||||
const sourceNodeId = variables[isRagVar ? 1 : 0]
|
||||
const isLlmModelInstalled = useLlmModelPluginInstalled(sourceNodeId, localWorkflowNodesMap)
|
||||
const variableValid = useMemo(() => {
|
||||
let variableValid = true
|
||||
const isEnv = isENV(variables)
|
||||
@@ -147,13 +144,7 @@ const WorkflowVariableBlockComponent = ({
|
||||
handleVariableJump()
|
||||
}}
|
||||
isExceptionVariable={isException}
|
||||
errorMsg={
|
||||
!variableValid
|
||||
? t('errorMsg.invalidVariable', { ns: 'workflow' })
|
||||
: !isLlmModelInstalled
|
||||
? t('errorMsg.modelPluginNotInstalled', { ns: 'workflow' })
|
||||
: undefined
|
||||
}
|
||||
errorMsg={!variableValid ? t('errorMsg.invalidVariable', { ns: 'workflow' }) : undefined}
|
||||
isSelected={isSelected}
|
||||
ref={ref}
|
||||
notShowFullPath={isShowAPart}
|
||||
@@ -164,9 +155,9 @@ const WorkflowVariableBlockComponent = ({
|
||||
return Item
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger disabled={!isShowAPart} render={<div>{Item}</div>} />
|
||||
<TooltipContent variant="plain">
|
||||
<Tooltip
|
||||
noDecoration
|
||||
popupContent={(
|
||||
<VarFullPathPanel
|
||||
nodeName={node.title}
|
||||
path={variables.slice(1)}
|
||||
@@ -178,7 +169,10 @@ const WorkflowVariableBlockComponent = ({
|
||||
: Type.string}
|
||||
nodeType={node?.type}
|
||||
/>
|
||||
</TooltipContent>
|
||||
)}
|
||||
disabled={!isShowAPart}
|
||||
>
|
||||
<div>{Item}</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { WorkflowNodesMap } from '@/app/components/base/prompt-editor/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { extractPluginId } from '@/app/components/workflow/utils/plugin'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
|
||||
export function useLlmModelPluginInstalled(
|
||||
nodeId: string,
|
||||
workflowNodesMap: WorkflowNodesMap | undefined,
|
||||
): boolean {
|
||||
const node = workflowNodesMap?.[nodeId]
|
||||
const modelProvider = node?.type === BlockEnum.LLM
|
||||
? node.modelProvider
|
||||
: undefined
|
||||
const modelPluginId = modelProvider ? extractPluginId(modelProvider) : undefined
|
||||
|
||||
return useProviderContextSelector((state) => {
|
||||
if (!modelPluginId)
|
||||
return true
|
||||
return state.modelProviders.some(p =>
|
||||
extractPluginId(p.provider) === modelPluginId,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export type GetVarType = (payload: {
|
||||
export type WorkflowVariableBlockType = {
|
||||
show?: boolean
|
||||
variables?: NodeOutPutVar[]
|
||||
workflowNodesMap?: WorkflowNodesMap
|
||||
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'>>
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
getVarType?: GetVarType
|
||||
@@ -81,14 +81,12 @@ export type WorkflowVariableBlockType = {
|
||||
onManageInputField?: () => void
|
||||
}
|
||||
|
||||
export type WorkflowNodesMap = Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'> & { modelProvider?: string }>
|
||||
|
||||
export type HITLInputBlockType = {
|
||||
show?: boolean
|
||||
nodeId: string
|
||||
formInputs?: FormInputItem[]
|
||||
variables?: NodeOutPutVar[]
|
||||
workflowNodesMap?: WorkflowNodesMap
|
||||
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'>>
|
||||
getVarType?: GetVarType
|
||||
onFormInputsChange?: (inputs: FormInputItem[]) => void
|
||||
onFormInputItemRemove: (varName: string) => void
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,24 +46,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-[1002] 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
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export enum CategoryEnum {
|
||||
CLOUD = 'cloud',
|
||||
SELF = 'self',
|
||||
}
|
||||
|
||||
export type Category = CategoryEnum.CLOUD | CategoryEnum.SELF
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -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()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -125,13 +125,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">
|
||||
|
||||
@@ -144,12 +144,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>
|
||||
@@ -193,8 +193,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' })}
|
||||
|
||||
@@ -63,7 +63,7 @@ const SummaryIndexSetting = ({
|
||||
return (
|
||||
<div>
|
||||
<div className="flex h-6 items-center justify-between">
|
||||
<div className="flex items-center text-text-secondary system-sm-semibold-uppercase">
|
||||
<div className="system-sm-semibold-uppercase flex items-center text-text-secondary">
|
||||
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
|
||||
<Tooltip
|
||||
triggerClassName="ml-1 h-4 w-4 shrink-0"
|
||||
@@ -80,7 +80,7 @@ const SummaryIndexSetting = ({
|
||||
{
|
||||
summaryIndexSetting?.enable && (
|
||||
<div>
|
||||
<div className="mb-1.5 mt-2 flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
|
||||
<div className="system-xs-medium-uppercase mb-1.5 mt-2 flex h-6 items-center text-text-tertiary">
|
||||
{t('form.summaryModel', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<ModelSelector
|
||||
@@ -90,7 +90,7 @@ const SummaryIndexSetting = ({
|
||||
readonly={readonly}
|
||||
showDeprecatedWarnIcon
|
||||
/>
|
||||
<div className="mt-3 flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
|
||||
<div className="system-xs-medium-uppercase mt-3 flex h-6 items-center text-text-tertiary">
|
||||
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<Textarea
|
||||
@@ -111,12 +111,12 @@ const SummaryIndexSetting = ({
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-x-1">
|
||||
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
|
||||
<div className="text-text-secondary system-sm-semibold">
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1.5">
|
||||
<div className="flex items-center text-text-secondary system-sm-semibold">
|
||||
<div className="system-sm-semibold flex items-center text-text-secondary">
|
||||
<Switch
|
||||
className="mr-2"
|
||||
value={summaryIndexSetting?.enable ?? false}
|
||||
@@ -127,7 +127,7 @@ const SummaryIndexSetting = ({
|
||||
summaryIndexSetting?.enable ? t('list.status.enabled', { ns: 'datasetDocuments' }) : t('list.status.disabled', { ns: 'datasetDocuments' })
|
||||
}
|
||||
</div>
|
||||
<div className="mt-2 text-text-tertiary system-sm-regular">
|
||||
<div className="system-sm-regular mt-2 text-text-tertiary">
|
||||
{
|
||||
summaryIndexSetting?.enable && t('form.summaryAutoGenTip', { ns: 'datasetSettings' })
|
||||
}
|
||||
@@ -142,7 +142,7 @@ const SummaryIndexSetting = ({
|
||||
<>
|
||||
<div className="flex gap-x-1">
|
||||
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
|
||||
<div className="text-text-tertiary system-sm-medium">
|
||||
<div className="system-sm-medium text-text-tertiary">
|
||||
{t('form.summaryModel', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +159,7 @@ const SummaryIndexSetting = ({
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
|
||||
<div className="text-text-tertiary system-sm-medium">
|
||||
<div className="system-sm-medium text-text-tertiary">
|
||||
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +188,7 @@ const SummaryIndexSetting = ({
|
||||
onChange={handleSummaryIndexEnableChange}
|
||||
size="md"
|
||||
/>
|
||||
<div className="text-text-secondary system-sm-semibold">
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,7 +196,7 @@ const SummaryIndexSetting = ({
|
||||
summaryIndexSetting?.enable && (
|
||||
<>
|
||||
<div>
|
||||
<div className="mb-1.5 flex h-6 items-center text-text-secondary system-sm-medium">
|
||||
<div className="system-sm-medium mb-1.5 flex h-6 items-center text-text-secondary">
|
||||
{t('form.summaryModel', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<ModelSelector
|
||||
@@ -209,7 +209,7 @@ const SummaryIndexSetting = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1.5 flex h-6 items-center text-text-secondary system-sm-medium">
|
||||
<div className="system-sm-medium mb-1.5 flex h-6 items-center text-text-secondary">
|
||||
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<Textarea
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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(() => {
|
||||
@@ -910,38 +913,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', {
|
||||
@@ -1304,52 +1275,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())
|
||||
|
||||
@@ -1357,30 +1315,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())
|
||||
|
||||
@@ -1388,43 +1339,17 @@ describe('hooks', () => {
|
||||
result.current.handleRefreshModel(provider, undefined, true)
|
||||
})
|
||||
|
||||
expect(expandModelProviderList).not.toHaveBeenCalled()
|
||||
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
||||
queryKey: modelProviderModelListQueryKey,
|
||||
exact: true,
|
||||
refetchType: 'active',
|
||||
})
|
||||
expect(emit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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',
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate all supported model types when __model_type is undefined', () => {
|
||||
it('should emit event and invalidate all supported model types when __model_type is undefined', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
const emit = vi.fn()
|
||||
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
; (useEventEmitterContextContext as Mock).mockReturnValue({
|
||||
eventEmitter: { emit },
|
||||
})
|
||||
|
||||
const provider = createMockProvider()
|
||||
const customFields = { __model_name: 'my-model', __model_type: undefined } as unknown as CustomConfigurationModelFixedFields
|
||||
@@ -1435,7 +1360,11 @@ describe('hooks', () => {
|
||||
result.current.handleRefreshModel(provider, customFields, true)
|
||||
})
|
||||
|
||||
// When __model_type is undefined, all supported model types are invalidated.
|
||||
expect(emit).toHaveBeenCalledWith({
|
||||
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
|
||||
payload: 'openai',
|
||||
})
|
||||
// When __model_type is undefined, all supported model types are invalidated
|
||||
const modelListCalls = invalidateQueries.mock.calls.filter(
|
||||
call => call[0]?.queryKey?.[0] === 'model-list',
|
||||
)
|
||||
@@ -1446,6 +1375,9 @@ describe('hooks', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
; (useEventEmitterContextContext as Mock).mockReturnValue({
|
||||
eventEmitter: { emit: vi.fn() },
|
||||
})
|
||||
|
||||
const provider = {
|
||||
...createMockProvider(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,21 @@ 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 },
|
||||
type MockDefaultModelData = {
|
||||
model: string
|
||||
provider?: { provider: string }
|
||||
} | null
|
||||
|
||||
const mockDefaultModelState: {
|
||||
data: MockDefaultModelData
|
||||
isLoading: boolean
|
||||
} = {
|
||||
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 +93,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 +157,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',
|
||||
@@ -262,4 +204,129 @@ describe('ModelProviderPage', () => {
|
||||
])
|
||||
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show not configured alert when all default models are absent', () => {
|
||||
mockDefaultModelState.data = null
|
||||
mockDefaultModelState.isLoading = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show not configured alert when default model is loading', () => {
|
||||
mockDefaultModelState.data = null
|
||||
mockDefaultModelState.isLoading = true
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter providers by label text', () => {
|
||||
render(<ModelProviderPage searchText="OpenAI" />)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||
expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should classify system-enabled providers with matching quota as configured', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'sys-provider',
|
||||
label: { en_US: 'System Provider' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
})
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.getByText('sys-provider')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should classify system-enabled provider with no matching quota as not configured', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'sys-no-quota',
|
||||
label: { en_US: 'System No Quota' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [],
|
||||
},
|
||||
})
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.getByText('sys-no-quota')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve order of two non-fixed providers (sort returns 0)', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'alpha-provider',
|
||||
label: { en_US: 'Alpha Provider' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
}, {
|
||||
provider: 'beta-provider',
|
||||
label: { en_US: 'Beta Provider' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
})
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
|
||||
expect(renderedProviders).toEqual(['alpha-provider', 'beta-provider'])
|
||||
})
|
||||
|
||||
it('should not show not configured alert when shared default model mock has data', () => {
|
||||
mockDefaultModelState.data = { model: 'embed-model' }
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show not configured alert when rerankDefaultModel has data', () => {
|
||||
mockDefaultModelState.data = { model: 'rerank-model', provider: { provider: 'cohere' } }
|
||||
mockDefaultModelState.isLoading = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show not configured alert when ttsDefaultModel has data', () => {
|
||||
mockDefaultModelState.data = { model: 'tts-model', provider: { provider: 'openai' } }
|
||||
mockDefaultModelState.isLoading = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show not configured alert when speech2textDefaultModel has data', () => {
|
||||
mockDefaultModelState.data = { model: 'whisper', provider: { provider: 'openai' } }
|
||||
mockDefaultModelState.isLoading = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -2,6 +2,10 @@ import type {
|
||||
ModelProvider,
|
||||
} from './declarations'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightUpLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useState } from 'react'
|
||||
@@ -43,15 +47,15 @@ const InstallFromMarketplace = ({
|
||||
<div className="mb-2">
|
||||
<Divider className="!mt-4 h-px" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex cursor-pointer items-center gap-1 text-text-primary system-md-semibold" onClick={() => setCollapse(!collapse)}>
|
||||
<span className={cn('i-ri-arrow-down-s-line h-4 w-4', collapse && '-rotate-90')} />
|
||||
<div className="system-md-semibold flex cursor-pointer items-center gap-1 text-text-primary" onClick={() => setCollapse(!collapse)}>
|
||||
<RiArrowDownSLine className={cn('h-4 w-4', collapse && '-rotate-90')} />
|
||||
{t('modelProvider.installProvider', { ns: 'common' })}
|
||||
</div>
|
||||
<div className="mb-2 flex items-center pt-2">
|
||||
<span className="pr-1 text-text-tertiary system-sm-regular">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
|
||||
<Link target="_blank" href={getMarketplaceUrl('', { theme })} className="inline-flex items-center text-text-accent system-sm-medium">
|
||||
<span className="system-sm-regular pr-1 text-text-tertiary">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
|
||||
<Link target="_blank" href={getMarketplaceUrl('', { theme })} className="system-sm-medium inline-flex items-center text-text-accent">
|
||||
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
|
||||
<span className="i-ri-arrow-right-up-line h-4 w-4" />
|
||||
<RiArrowRightUpLine className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -122,16 +121,14 @@ describe('CredentialItem', () => {
|
||||
|
||||
render(<CredentialItem credential={credential} disabled onDelete={onDelete} />)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
// showSelectedIcon=true: check icon area is always rendered; check icon only appears when IDs match
|
||||
it('should render check icon area when showSelectedIcon=true and selectedCredentialId matches', () => {
|
||||
const { container } = render(
|
||||
render(
|
||||
<CredentialItem
|
||||
credential={credential}
|
||||
showSelectedIcon
|
||||
@@ -139,7 +136,7 @@ describe('CredentialItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.i-ri-check-line')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('check-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render check icon when showSelectedIcon=true but selectedCredentialId does not match', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,14 +53,4 @@ describe('useCredentialStatus', () => {
|
||||
expect(result.current.hasCredential).toBe(false)
|
||||
expect(result.current.available_credentials).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles undefined provider gracefully', () => {
|
||||
const { result } = renderHook(() => useCredentialStatus(undefined))
|
||||
expect(result.current.hasCredential).toBe(false)
|
||||
expect(result.current.authorized).toBeFalsy()
|
||||
expect(result.current.authRemoved).toBe(false)
|
||||
expect(result.current.available_credentials).toBeUndefined()
|
||||
expect(result.current.current_credential_id).toBeUndefined()
|
||||
expect(result.current.current_credential_name).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,12 +3,12 @@ import type {
|
||||
} from '../../declarations'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export const useCredentialStatus = (provider: ModelProvider | undefined) => {
|
||||
export const useCredentialStatus = (provider: ModelProvider) => {
|
||||
const {
|
||||
current_credential_id,
|
||||
current_credential_name,
|
||||
available_credentials,
|
||||
} = provider?.custom_configuration ?? {}
|
||||
} = provider.custom_configuration
|
||||
const hasCredential = !!available_credentials?.length
|
||||
const authorized = current_credential_id && current_credential_name
|
||||
const authRemoved = hasCredential && !current_credential_id && !current_credential_name
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Credential, CredentialFormSchema, ModelProvider } from '../declarations'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { Credential, CredentialFormSchema, CustomModel, ModelProvider } from '../declarations'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
@@ -43,15 +45,6 @@ const mockHandlers = vi.hoisted(() => ({
|
||||
handleActiveCredential: vi.fn(),
|
||||
}))
|
||||
|
||||
type FormResponse = {
|
||||
isCheckValidated: boolean
|
||||
values: Record<string, unknown>
|
||||
}
|
||||
const mockFormState = vi.hoisted(() => ({
|
||||
responses: [] as FormResponse[],
|
||||
setFieldValue: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../model-auth/hooks', () => ({
|
||||
useCredentialData: () => ({
|
||||
isLoading: mockState.isLoading,
|
||||
@@ -86,36 +79,6 @@ vi.mock('../hooks', () => ({
|
||||
useLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/auth', async () => {
|
||||
const React = await import('react')
|
||||
const AuthForm = React.forwardRef(({
|
||||
onChange,
|
||||
}: {
|
||||
onChange?: (field: string, value: string) => void
|
||||
}, ref: React.ForwardedRef<{ getFormValues: () => FormResponse, getForm: () => { setFieldValue: (field: string, value: string) => void } }>) => {
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getFormValues: () => mockFormState.responses.shift() || { isCheckValidated: false, values: {} },
|
||||
getForm: () => ({ setFieldValue: mockFormState.setFieldValue }),
|
||||
}))
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => onChange?.('__model_name', 'updated-model')}>Model Name Change</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
return { default: AuthForm }
|
||||
})
|
||||
|
||||
vi.mock('../model-auth', () => ({
|
||||
CredentialSelector: ({ onSelect }: { onSelect: (credential: Credential & { addNewCredential?: boolean }) => void }) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onSelect({ credential_id: 'existing' })}>Choose Existing</button>
|
||||
<button type="button" onClick={() => onSelect({ credential_id: 'new', addNewCredential: true })}>Add New</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
|
||||
|
||||
const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
|
||||
@@ -158,7 +121,7 @@ const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderModal = (overrides?: Partial<React.ComponentProps<typeof ModelModal>>) => {
|
||||
const renderModal = (overrides?: Partial<ComponentProps<typeof ModelModal>>) => {
|
||||
const provider = createProvider()
|
||||
const props = {
|
||||
provider,
|
||||
@@ -168,13 +131,50 @@ const renderModal = (overrides?: Partial<React.ComponentProps<typeof ModelModal>
|
||||
onRemove: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<ModelModal {...props} />)
|
||||
return {
|
||||
...props,
|
||||
unmount: view.unmount,
|
||||
}
|
||||
render(<ModelModal {...props} />)
|
||||
return props
|
||||
}
|
||||
|
||||
const mockFormRef1 = {
|
||||
getFormValues: vi.fn(),
|
||||
getForm: vi.fn(() => ({ setFieldValue: vi.fn() })),
|
||||
}
|
||||
|
||||
const mockFormRef2 = {
|
||||
getFormValues: vi.fn(),
|
||||
getForm: vi.fn(() => ({ setFieldValue: vi.fn() })),
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
|
||||
default: React.forwardRef((props: { formSchemas: Record<string, unknown>[], onChange?: (f: string, v: string) => void }, ref: React.ForwardedRef<unknown>) => {
|
||||
React.useImperativeHandle(ref, () => {
|
||||
// Return the mock depending on schemas passed (hacky but works for refs)
|
||||
if (props.formSchemas.length > 0 && props.formSchemas[0].name === '__model_name')
|
||||
return mockFormRef1
|
||||
return mockFormRef2
|
||||
})
|
||||
return (
|
||||
<div data-testid="auth-form" onClick={() => props.onChange?.('test-field', 'val')}>
|
||||
AuthForm Mock (
|
||||
{props.formSchemas.length}
|
||||
{' '}
|
||||
fields)
|
||||
</div>
|
||||
)
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../model-auth', () => ({
|
||||
CredentialSelector: ({ onSelect }: { onSelect: (val: unknown) => void }) => (
|
||||
<button onClick={() => onSelect({ addNewCredential: true })} data-testid="credential-selector">
|
||||
Select Credential
|
||||
</button>
|
||||
),
|
||||
useAuth: vi.fn(),
|
||||
useCredentialData: vi.fn(),
|
||||
useModelFormSchemas: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('ModelModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -187,168 +187,131 @@ describe('ModelModal', () => {
|
||||
mockState.formValues = {}
|
||||
mockState.modelNameAndTypeFormSchemas = []
|
||||
mockState.modelNameAndTypeFormValues = {}
|
||||
mockFormState.responses = []
|
||||
|
||||
// reset form refs
|
||||
mockFormRef1.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __model_name: 'test', __model_type: ModelTypeEnum.textGeneration } })
|
||||
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __authorization_name__: 'test_auth', api_key: 'sk-test' } })
|
||||
})
|
||||
|
||||
it('should show title, description, and loading state for predefined models', () => {
|
||||
it('should render title and loading state for predefined credential modal', () => {
|
||||
mockState.isLoading = true
|
||||
|
||||
const predefined = renderModal()
|
||||
|
||||
renderModal()
|
||||
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.desc')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
predefined.unmount()
|
||||
const customizable = renderModal({ configurateMethod: ConfigurationMethodEnum.customizableModel })
|
||||
expect(screen.queryByText('common.modelProvider.auth.apiKeyModal.desc')).not.toBeInTheDocument()
|
||||
customizable.unmount()
|
||||
|
||||
mockState.credentialData = { credentials: {}, available_credentials: [] }
|
||||
renderModal({ mode: ModelModalModeEnum.configModelCredential, model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } })
|
||||
it('should render model credential title when mode is configModelCredential', () => {
|
||||
renderModal({
|
||||
mode: ModelModalModeEnum.configModelCredential,
|
||||
model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration },
|
||||
})
|
||||
expect(screen.getByText('common.modelProvider.auth.addModelCredential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reveal the credential label when adding a new credential', () => {
|
||||
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList })
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.auth.modelCredential')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Add New'))
|
||||
|
||||
expect(screen.getByText('common.modelProvider.auth.modelCredential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when the cancel button is clicked', () => {
|
||||
const { onCancel } = renderModal()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCancel when the escape key is pressed', () => {
|
||||
const { onCancel } = renderModal()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should confirm deletion when a delete dialog is shown', () => {
|
||||
mockState.credentialData = { credentials: { api_key: 'secret' }, available_credentials: [] }
|
||||
mockState.deleteCredentialId = 'delete-id'
|
||||
|
||||
const credential: Credential = { credential_id: 'cred-1' }
|
||||
const { onCancel } = renderModal({ credential })
|
||||
|
||||
const alertDialog = screen.getByRole('alertdialog', { hidden: true })
|
||||
expect(alertDialog).toHaveTextContent('common.modelProvider.confirmDelete')
|
||||
|
||||
fireEvent.click(within(alertDialog).getByRole('button', { hidden: true, name: 'common.operation.confirm' }))
|
||||
|
||||
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalledTimes(1)
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle save flows for different modal modes', async () => {
|
||||
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text-input' } as unknown as CredentialFormSchema]
|
||||
mockState.formSchemas = [{ variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema]
|
||||
mockFormState.responses = [
|
||||
{ isCheckValidated: true, values: { __model_name: 'custom-model', __model_type: ModelTypeEnum.textGeneration } },
|
||||
{ isCheckValidated: true, values: { __authorization_name__: 'Auth Name', api_key: 'secret' } },
|
||||
]
|
||||
const configCustomModel = renderModal({ mode: ModelModalModeEnum.configCustomModel })
|
||||
fireEvent.click(screen.getAllByText('Model Name Change')[0])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||
|
||||
expect(mockFormState.setFieldValue).toHaveBeenCalledWith('__model_name', 'updated-model')
|
||||
await waitFor(() => {
|
||||
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
||||
credential_id: undefined,
|
||||
credentials: { api_key: 'secret' },
|
||||
name: 'Auth Name',
|
||||
model: 'custom-model',
|
||||
model_type: ModelTypeEnum.textGeneration,
|
||||
})
|
||||
})
|
||||
expect(configCustomModel.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Auth Name', api_key: 'secret' })
|
||||
configCustomModel.unmount()
|
||||
|
||||
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Model Auth', api_key: 'abc' } }]
|
||||
const model = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration }
|
||||
const configModelCredential = renderModal({
|
||||
it('should render edit credential title when credential exists', () => {
|
||||
renderModal({
|
||||
mode: ModelModalModeEnum.configModelCredential,
|
||||
model,
|
||||
credential: { credential_id: 'cred-123' },
|
||||
credential: { credential_id: '1' } as unknown as Credential,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
await waitFor(() => {
|
||||
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
||||
credential_id: 'cred-123',
|
||||
credentials: { api_key: 'abc' },
|
||||
name: 'Model Auth',
|
||||
model: 'gpt-4',
|
||||
model_type: ModelTypeEnum.textGeneration,
|
||||
})
|
||||
})
|
||||
expect(configModelCredential.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Model Auth', api_key: 'abc' })
|
||||
configModelCredential.unmount()
|
||||
expect(screen.getByText('common.modelProvider.auth.editModelCredential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should change title to Add Model when mode is configCustomModel', () => {
|
||||
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
|
||||
renderModal({ mode: ModelModalModeEnum.configCustomModel })
|
||||
expect(screen.getByText('common.modelProvider.auth.addModel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should validate and fail save if form is invalid in configCustomModel mode', async () => {
|
||||
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
|
||||
mockFormRef1.getFormValues.mockReturnValue({ isCheckValidated: false, values: {} })
|
||||
renderModal({ mode: ModelModalModeEnum.configCustomModel })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||
expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should validate and save new credential and model in configCustomModel mode', async () => {
|
||||
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
|
||||
const props = renderModal({ mode: ModelModalModeEnum.configCustomModel })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||
|
||||
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Provider Auth', api_key: 'provider-key' } }]
|
||||
const configProviderCredential = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
await waitFor(() => {
|
||||
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
||||
credential_id: undefined,
|
||||
credentials: { api_key: 'provider-key' },
|
||||
name: 'Provider Auth',
|
||||
credentials: { api_key: 'sk-test' },
|
||||
name: 'test_auth',
|
||||
model: 'test',
|
||||
model_type: ModelTypeEnum.textGeneration,
|
||||
})
|
||||
expect(props.onSave).toHaveBeenCalled()
|
||||
})
|
||||
configProviderCredential.unmount()
|
||||
})
|
||||
|
||||
const addToModelList = renderModal({
|
||||
mode: ModelModalModeEnum.addCustomModelToModelList,
|
||||
model,
|
||||
})
|
||||
fireEvent.click(screen.getByText('Choose Existing'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||
expect(mockHandlers.handleActiveCredential).toHaveBeenCalledWith({ credential_id: 'existing' }, model)
|
||||
expect(addToModelList.onCancel).toHaveBeenCalled()
|
||||
addToModelList.unmount()
|
||||
it('should save credential only in standard configProviderCredential mode', async () => {
|
||||
const { onSave } = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'New Auth', api_key: 'new-key' } }]
|
||||
const addToModelListWithNew = renderModal({
|
||||
mode: ModelModalModeEnum.addCustomModelToModelList,
|
||||
model,
|
||||
})
|
||||
fireEvent.click(screen.getByText('Add New'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||
await waitFor(() => {
|
||||
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
||||
credential_id: undefined,
|
||||
credentials: { api_key: 'new-key' },
|
||||
name: 'New Auth',
|
||||
model: 'gpt-4',
|
||||
credentials: { api_key: 'sk-test' },
|
||||
name: 'test_auth',
|
||||
})
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should save active credential and cancel when picking existing credential in addCustomModelToModelList mode', async () => {
|
||||
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList, model: { model: 'm1', model_type: ModelTypeEnum.textGeneration } as unknown as CustomModel })
|
||||
// By default selected is undefined so button clicks form
|
||||
// Let's not click credential selector, so it evaluates without it. If selectedCredential is undefined, form validation is checked.
|
||||
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: false, values: {} })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||
expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should save active credential when picking existing credential in addCustomModelToModelList mode', async () => {
|
||||
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList, model: { model: 'm2', model_type: ModelTypeEnum.textGeneration } as unknown as CustomModel })
|
||||
|
||||
// Select existing credential (addNewCredential: true simulates new but we can simulate false if we just hack the mocked state in the component, but it's internal.
|
||||
// The credential selector sets selectedCredential.
|
||||
fireEvent.click(screen.getByTestId('credential-selector')) // Sets addNewCredential = true internally, so it proceeds to form save
|
||||
|
||||
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __authorization_name__: 'auth', api: 'key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
||||
credential_id: undefined,
|
||||
credentials: { api: 'key' },
|
||||
name: 'auth',
|
||||
model: 'm2',
|
||||
model_type: ModelTypeEnum.textGeneration,
|
||||
})
|
||||
})
|
||||
addToModelListWithNew.unmount()
|
||||
})
|
||||
|
||||
mockFormState.responses = [{ isCheckValidated: false, values: {} }]
|
||||
const invalidSave = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
await waitFor(() => {
|
||||
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
invalidSave.unmount()
|
||||
it('should open and confirm deletion of credential', () => {
|
||||
mockState.credentialData = { credentials: { api_key: '123' }, available_credentials: [] }
|
||||
mockState.formValues = { api_key: '123' } // To trigger isEditMode = true
|
||||
const credential = { credential_id: 'c1' } as unknown as Credential
|
||||
renderModal({ credential })
|
||||
|
||||
mockState.credentialData = { credentials: { api_key: 'value' }, available_credentials: [] }
|
||||
mockState.formValues = { api_key: 'value' }
|
||||
const removable = renderModal({ credential: { credential_id: 'remove-1' } })
|
||||
// Open Delete Confirm
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
|
||||
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith({ credential_id: 'remove-1' }, undefined)
|
||||
removable.unmount()
|
||||
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith(credential, undefined)
|
||||
|
||||
// Simulate the dialog appearing and confirming
|
||||
mockState.deleteCredentialId = 'c1'
|
||||
renderModal({ credential }) // Re-render logic mock
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.confirm' })[0])
|
||||
|
||||
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should bind escape key to cancel', () => {
|
||||
const props = renderModal()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(props.onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useModelParameterRules } from '@/service/use-common'
|
||||
@@ -129,118 +129,117 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (readonly)
|
||||
return
|
||||
setOpen(newOpen)
|
||||
}}
|
||||
onOpenChange={setOpen}
|
||||
placement={isInWorkflow ? 'left' : 'bottom-end'}
|
||||
offset={4}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className="block">
|
||||
{
|
||||
renderTrigger
|
||||
? renderTrigger({
|
||||
open,
|
||||
disabled,
|
||||
modelDisabled,
|
||||
hasDeprecated,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
providerName: provider,
|
||||
modelId,
|
||||
})
|
||||
: (
|
||||
<Trigger
|
||||
disabled={disabled}
|
||||
isInWorkflow={isInWorkflow}
|
||||
modelDisabled={modelDisabled}
|
||||
hasDeprecated={hasDeprecated}
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName={provider}
|
||||
modelId={modelId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={isInWorkflow ? 'left' : 'bottom-end'}
|
||||
sideOffset={4}
|
||||
className={portalToFollowElemContentClassName}
|
||||
popupClassName={cn(popupClassName, 'w-[389px] rounded-2xl')}
|
||||
>
|
||||
<div className="max-h-[420px] overflow-y-auto p-4 pt-3">
|
||||
<div className="relative">
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">
|
||||
{t('modelProvider.model', { ns: 'common' }).toLocaleUpperCase()}
|
||||
</div>
|
||||
<ModelSelector
|
||||
defaultModel={(provider || modelId) ? { provider, model: modelId } : undefined}
|
||||
modelList={activeTextGenerationModelList}
|
||||
onSelect={handleChangeModel}
|
||||
onHide={() => setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => {
|
||||
if (readonly)
|
||||
return
|
||||
setOpen(v => !v)
|
||||
}}
|
||||
className="block"
|
||||
>
|
||||
{
|
||||
!!parameterRules.length && (
|
||||
<div className="my-3 h-px bg-divider-subtle" />
|
||||
)
|
||||
renderTrigger
|
||||
? renderTrigger({
|
||||
open,
|
||||
disabled,
|
||||
modelDisabled,
|
||||
hasDeprecated,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
providerName: provider,
|
||||
modelId,
|
||||
})
|
||||
: (
|
||||
<Trigger
|
||||
disabled={disabled}
|
||||
isInWorkflow={isInWorkflow}
|
||||
modelDisabled={modelDisabled}
|
||||
hasDeprecated={hasDeprecated}
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName={provider}
|
||||
modelId={modelId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
isLoading && (
|
||||
<div className="mt-5"><Loading /></div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && !!parameterRules.length && (
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex h-6 items-center text-text-secondary system-sm-semibold">{t('modelProvider.parameters', { ns: 'common' })}</div>
|
||||
{
|
||||
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
|
||||
<PresetsParameter onSelect={handleSelectPresetParameter} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && !!parameterRules.length && (
|
||||
[
|
||||
...parameterRules,
|
||||
...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []),
|
||||
].map(parameter => (
|
||||
<ParameterItem
|
||||
key={`${modelId}-${parameter.name}`}
|
||||
parameterRule={parameter}
|
||||
value={completionParams?.[parameter.name]}
|
||||
onChange={v => handleParamChange(parameter.name, v)}
|
||||
onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)}
|
||||
isInWorkflow={isInWorkflow}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={cn('z-[60]', portalToFollowElemContentClassName)}>
|
||||
<div className={cn(popupClassName, 'w-[389px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg')}>
|
||||
<div className={cn('max-h-[420px] overflow-y-auto p-4 pt-3')}>
|
||||
<div className="relative">
|
||||
<div className={cn('system-sm-semibold mb-1 flex h-6 items-center text-text-secondary')}>
|
||||
{t('modelProvider.model', { ns: 'common' }).toLocaleUpperCase()}
|
||||
</div>
|
||||
<ModelSelector
|
||||
defaultModel={(provider || modelId) ? { provider, model: modelId } : undefined}
|
||||
modelList={activeTextGenerationModelList}
|
||||
onSelect={handleChangeModel}
|
||||
/>
|
||||
))
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{!hideDebugWithMultipleModel && (
|
||||
<div
|
||||
className="flex h-[50px] cursor-pointer items-center justify-between rounded-b-xl border-t border-t-divider-subtle px-4 text-text-accent system-sm-regular"
|
||||
onClick={() => onDebugWithMultipleModelChange?.()}
|
||||
>
|
||||
{
|
||||
debugWithMultipleModel
|
||||
? t('debugAsSingleModel', { ns: 'appDebug' })
|
||||
: t('debugAsMultipleModel', { ns: 'appDebug' })
|
||||
}
|
||||
<ArrowNarrowLeft className="h-3 w-3 rotate-180" />
|
||||
</div>
|
||||
{
|
||||
!!parameterRules.length && (
|
||||
<div className="my-3 h-px bg-divider-subtle" />
|
||||
)
|
||||
}
|
||||
{
|
||||
isLoading && (
|
||||
<div className="mt-5"><Loading /></div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && !!parameterRules.length && (
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className={cn('system-sm-semibold flex h-6 items-center text-text-secondary')}>{t('modelProvider.parameters', { ns: 'common' })}</div>
|
||||
{
|
||||
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
|
||||
<PresetsParameter onSelect={handleSelectPresetParameter} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && !!parameterRules.length && (
|
||||
[
|
||||
...parameterRules,
|
||||
...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []),
|
||||
].map(parameter => (
|
||||
<ParameterItem
|
||||
key={`${modelId}-${parameter.name}`}
|
||||
parameterRule={parameter}
|
||||
value={completionParams?.[parameter.name]}
|
||||
onChange={v => handleParamChange(parameter.name, v)}
|
||||
onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)}
|
||||
isInWorkflow={isInWorkflow}
|
||||
/>
|
||||
))
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{!hideDebugWithMultipleModel && (
|
||||
<div
|
||||
className="bg-components-section-burn system-sm-regular flex h-[50px] cursor-pointer items-center justify-between rounded-b-xl border-t border-t-divider-subtle px-4 text-text-accent"
|
||||
onClick={() => onDebugWithMultipleModelChange?.()}
|
||||
>
|
||||
{
|
||||
debugWithMultipleModel
|
||||
? t('debugAsSingleModel', { ns: 'appDebug' })
|
||||
: t('debugAsMultipleModel', { ns: 'appDebug' })
|
||||
}
|
||||
<ArrowNarrowLeft className="h-3 w-3 rotate-180" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PortalToFollowElemContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import DeprecatedModelTrigger from './deprecated-model-trigger'
|
||||
|
||||
vi.mock('../model-icon', () => ({
|
||||
default: ({ modelName }: { modelName: string }) => <span>{modelName}</span>,
|
||||
}))
|
||||
|
||||
const mockUseProviderContext = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: mockUseProviderContext,
|
||||
}))
|
||||
|
||||
describe('DeprecatedModelTrigger', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [{ provider: 'someone-else' }, { provider: 'openai' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should render model name', () => {
|
||||
render(<DeprecatedModelTrigger modelName="gpt-deprecated" providerName="openai" />)
|
||||
expect(screen.getAllByText('gpt-deprecated').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show deprecated tooltip when warn icon is hovered', async () => {
|
||||
const { container } = render(
|
||||
<DeprecatedModelTrigger
|
||||
modelName="gpt-deprecated"
|
||||
providerName="openai"
|
||||
showWarnIcon
|
||||
/>,
|
||||
)
|
||||
|
||||
const tooltipTrigger = container.querySelector('[data-state]') as HTMLElement
|
||||
fireEvent.mouseEnter(tooltipTrigger)
|
||||
|
||||
expect(await screen.findByText('common.modelProvider.deprecated')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render when provider is not found', () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [{ provider: 'someone-else' }],
|
||||
})
|
||||
|
||||
render(<DeprecatedModelTrigger modelName="gpt-deprecated" providerName="openai" />)
|
||||
expect(screen.getAllByText('gpt-deprecated').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not show deprecated tooltip when warn icon is disabled', async () => {
|
||||
render(
|
||||
<DeprecatedModelTrigger
|
||||
modelName="gpt-deprecated"
|
||||
providerName="openai"
|
||||
showWarnIcon={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.deprecated')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ModelIcon from '../model-icon'
|
||||
|
||||
type ModelTriggerProps = {
|
||||
modelName: string
|
||||
providerName: string
|
||||
className?: string
|
||||
showWarnIcon?: boolean
|
||||
contentClassName?: string
|
||||
}
|
||||
const ModelTrigger: FC<ModelTriggerProps> = ({
|
||||
modelName,
|
||||
providerName,
|
||||
className,
|
||||
showWarnIcon,
|
||||
contentClassName,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { modelProviders } = useProviderContext()
|
||||
const currentProvider = modelProviders.find(provider => provider.provider === providerName)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group box-content flex h-8 grow cursor-pointer items-center gap-1 rounded-lg bg-components-input-bg-disabled p-[3px] pl-1', className)}
|
||||
>
|
||||
<div className={cn('flex w-full items-center', contentClassName)}>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 py-[1px]">
|
||||
<ModelIcon
|
||||
className="h-4 w-4"
|
||||
provider={currentProvider}
|
||||
modelName={modelName}
|
||||
/>
|
||||
<div className="system-sm-regular truncate text-components-input-text-filled">
|
||||
{modelName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
{showWarnIcon && (
|
||||
<Tooltip popupContent={t('modelProvider.deprecated', { ns: 'common' })}>
|
||||
<AlertTriangle className="h-4 w-4 text-text-warning-secondary" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelTrigger
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import EmptyTrigger from './empty-trigger'
|
||||
|
||||
describe('EmptyTrigger', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render configure model text', () => {
|
||||
render(<EmptyTrigger open={false} />)
|
||||
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// open=true: hover bg class present
|
||||
it('should apply hover background class when open is true', () => {
|
||||
// Act
|
||||
const { container } = render(<EmptyTrigger open={true} />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toHaveClass('bg-components-input-bg-hover')
|
||||
})
|
||||
|
||||
// className prop truthy: custom className appears on root
|
||||
it('should apply custom className when provided', () => {
|
||||
// Act
|
||||
const { container } = render(<EmptyTrigger open={false} className="custom-class" />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
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 = {
|
||||
open: boolean
|
||||
className?: string
|
||||
}
|
||||
const ModelTrigger: FC<ModelTriggerProps> = ({
|
||||
open,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'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 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
|
||||
className="truncate text-[13px] text-text-tertiary"
|
||||
title="Configure model"
|
||||
>
|
||||
{t('detailPanel.configureModel', { ns: 'plugin' })}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelTrigger
|
||||
@@ -7,12 +7,15 @@ import type {
|
||||
} from '../declarations'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useCurrentProviderAndModel } from '../hooks'
|
||||
import ModelSelectorTrigger from './model-selector-trigger'
|
||||
import DeprecatedModelTrigger from './deprecated-model-trigger'
|
||||
import EmptyTrigger from './empty-trigger'
|
||||
import ModelTrigger from './model-trigger'
|
||||
import Popup from './popup'
|
||||
|
||||
type ModelSelectorProps = {
|
||||
@@ -21,7 +24,6 @@ type ModelSelectorProps = {
|
||||
triggerClassName?: string
|
||||
popupClassName?: string
|
||||
onSelect?: (model: DefaultModel) => void
|
||||
onHide?: () => void
|
||||
readonly?: boolean
|
||||
scopeFeatures?: ModelFeatureEnum[]
|
||||
deprecatedClassName?: string
|
||||
@@ -33,11 +35,10 @@ const ModelSelector: FC<ModelSelectorProps> = ({
|
||||
triggerClassName,
|
||||
popupClassName,
|
||||
onSelect,
|
||||
onHide,
|
||||
readonly,
|
||||
scopeFeatures = [],
|
||||
deprecatedClassName,
|
||||
showDeprecatedWarnIcon = true,
|
||||
showDeprecatedWarnIcon = false,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const {
|
||||
@@ -55,54 +56,67 @@ const ModelSelector: FC<ModelSelectorProps> = ({
|
||||
onSelect({ provider, model: model.model })
|
||||
}
|
||||
|
||||
const handleToggle = () => {
|
||||
if (readonly)
|
||||
return
|
||||
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (readonly)
|
||||
return
|
||||
setOpen(newOpen)
|
||||
}}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full border-0 bg-transparent p-0 text-left"
|
||||
disabled={readonly}
|
||||
>
|
||||
<ModelSelectorTrigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
defaultModel={defaultModel}
|
||||
open={open}
|
||||
readonly={readonly}
|
||||
className={triggerClassName}
|
||||
deprecatedClassName={deprecatedClassName}
|
||||
showDeprecatedWarnIcon={showDeprecatedWarnIcon}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
className={popupClassName}
|
||||
popupClassName="overflow-hidden rounded-lg"
|
||||
popupProps={{ style: { minWidth: '320px', width: 'var(--anchor-width, auto)' } }}
|
||||
>
|
||||
<Popup
|
||||
defaultModel={defaultModel}
|
||||
modelList={modelList}
|
||||
onSelect={handleSelect}
|
||||
scopeFeatures={scopeFeatures}
|
||||
onHide={() => {
|
||||
setOpen(false)
|
||||
onHide?.()
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className={cn('relative')}>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={handleToggle}
|
||||
className="block"
|
||||
>
|
||||
{
|
||||
currentModel && currentProvider && (
|
||||
<ModelTrigger
|
||||
open={open}
|
||||
provider={currentProvider}
|
||||
model={currentModel}
|
||||
className={triggerClassName}
|
||||
readonly={readonly}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!currentModel && defaultModel && (
|
||||
<DeprecatedModelTrigger
|
||||
modelName={defaultModel?.model || ''}
|
||||
providerName={defaultModel?.provider || ''}
|
||||
className={triggerClassName}
|
||||
showWarnIcon={showDeprecatedWarnIcon}
|
||||
contentClassName={deprecatedClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!defaultModel && (
|
||||
<EmptyTrigger
|
||||
open={open}
|
||||
className={triggerClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={`z-[1002] ${popupClassName}`}>
|
||||
<Popup
|
||||
defaultModel={defaultModel}
|
||||
modelList={modelList}
|
||||
onSelect={handleSelect}
|
||||
scopeFeatures={scopeFeatures}
|
||||
onHide={() => setOpen(false)}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import type { Model, ModelItem } from '../declarations'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelFeatureEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '../declarations'
|
||||
import ModelSelectorTrigger from './model-selector-trigger'
|
||||
|
||||
const mockUseProviderContext = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: mockUseProviderContext,
|
||||
}))
|
||||
|
||||
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||
model: 'gpt-4',
|
||||
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
|
||||
model_type: ModelTypeEnum.textGeneration,
|
||||
features: [ModelFeatureEnum.vision],
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: { mode: 'chat', context_size: 4096 },
|
||||
load_balancing_enabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createModel = (overrides: Partial<Model> = {}): Model => ({
|
||||
provider: 'openai',
|
||||
icon_small: {
|
||||
en_US: 'https://example.com/openai-light.png',
|
||||
zh_Hans: 'https://example.com/openai-light.png',
|
||||
},
|
||||
icon_small_dark: {
|
||||
en_US: 'https://example.com/openai-dark.png',
|
||||
zh_Hans: 'https://example.com/openai-dark.png',
|
||||
},
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
models: [createModelItem()],
|
||||
status: ModelStatusEnum.active,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ModelSelectorTrigger', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [createModel()],
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render empty state when no model is selected', () => {
|
||||
const { container } = render(<ModelSelectorTrigger />)
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
|
||||
expect(container.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
|
||||
expect(container.firstElementChild).toHaveClass('bg-components-input-bg-normal')
|
||||
})
|
||||
|
||||
it('should render selected model details when model is active', () => {
|
||||
const currentProvider = createModel()
|
||||
const currentModel = createModelItem()
|
||||
const { container } = render(
|
||||
<ModelSelectorTrigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||
expect(screen.getByText('CHAT')).toBeInTheDocument()
|
||||
expect(container.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
|
||||
expect(container.firstElementChild).toHaveClass('bg-components-input-bg-normal')
|
||||
})
|
||||
|
||||
it('should render deprecated default model and disabled style when selection is missing', () => {
|
||||
const { container } = render(
|
||||
<ModelSelectorTrigger
|
||||
defaultModel={{ provider: 'openai', model: 'legacy-model' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('legacy-model')).toBeInTheDocument()
|
||||
expect(container.firstElementChild).toHaveClass('bg-components-input-bg-disabled')
|
||||
expect(container.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to root element', () => {
|
||||
const { container } = render(<ModelSelectorTrigger className="custom-trigger" />)
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('custom-trigger')
|
||||
})
|
||||
|
||||
it('should apply open background style when open is true and model is active', () => {
|
||||
const { container } = render(
|
||||
<ModelSelectorTrigger
|
||||
currentProvider={createModel()}
|
||||
currentModel={createModelItem()}
|
||||
open
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('bg-components-input-bg-hover')
|
||||
})
|
||||
|
||||
it('should hide the expand arrow when readonly is true', () => {
|
||||
const { container } = render(
|
||||
<ModelSelectorTrigger
|
||||
currentProvider={createModel()}
|
||||
currentModel={createModelItem()}
|
||||
readonly
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Status Handling', () => {
|
||||
it('should show status badge when selected model is not active and not readonly', () => {
|
||||
render(
|
||||
<ModelSelectorTrigger
|
||||
currentProvider={createModel()}
|
||||
currentModel={createModelItem({ status: ModelStatusEnum.noConfigure })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show status badge when selected model is readonly', () => {
|
||||
render(
|
||||
<ModelSelectorTrigger
|
||||
currentProvider={createModel()}
|
||||
currentModel={createModelItem({ status: ModelStatusEnum.noConfigure })}
|
||||
readonly
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.selector.configureRequired')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show incompatible tooltip when hovering no-permission status badge', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<ModelSelectorTrigger
|
||||
currentProvider={createModel()}
|
||||
currentModel={createModelItem({ status: ModelStatusEnum.noPermission })}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.hover(screen.getByText('common.modelProvider.selector.incompatible'))
|
||||
|
||||
expect(await screen.findByText('common.modelProvider.selector.incompatibleTip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should show deprecated tooltip when hovering warn icon', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(
|
||||
<ModelSelectorTrigger
|
||||
defaultModel={{ provider: 'openai', model: 'legacy-model' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const warnIcon = container.querySelector('.i-ri-alert-line')
|
||||
expect(warnIcon).toBeInTheDocument()
|
||||
|
||||
await user.hover(warnIcon as HTMLElement)
|
||||
|
||||
expect(await screen.findByText('common.modelProvider.deprecated')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render fallback icon when deprecated provider is not found', () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [],
|
||||
})
|
||||
const { container } = render(
|
||||
<ModelSelectorTrigger
|
||||
defaultModel={{ provider: 'unknown-provider', model: 'legacy-model' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('img[alt="model-icon"]')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,146 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import type {
|
||||
DefaultModel,
|
||||
Model,
|
||||
ModelItem,
|
||||
} from '../declarations'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { ModelStatusEnum } from '../declarations'
|
||||
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 ModelSelectorTriggerProps = {
|
||||
currentProvider?: Model
|
||||
currentModel?: ModelItem
|
||||
defaultModel?: DefaultModel
|
||||
open?: boolean
|
||||
readonly?: boolean
|
||||
className?: string
|
||||
deprecatedClassName?: string
|
||||
showDeprecatedWarnIcon?: boolean
|
||||
}
|
||||
|
||||
const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
|
||||
currentProvider,
|
||||
currentModel,
|
||||
defaultModel,
|
||||
open,
|
||||
readonly,
|
||||
className,
|
||||
deprecatedClassName,
|
||||
showDeprecatedWarnIcon = true,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { modelProviders } = useProviderContext()
|
||||
|
||||
const isSelected = !!currentProvider && !!currentModel
|
||||
const isDeprecated = !isSelected && !!defaultModel
|
||||
const isEmpty = !isSelected && !defaultModel
|
||||
|
||||
const isActive = isSelected && currentModel.status === ModelStatusEnum.active
|
||||
const isDisabled = isDeprecated || (isSelected && !isActive)
|
||||
const statusI18nKey = isSelected ? STATUS_I18N_KEY[currentModel.status] : undefined
|
||||
|
||||
const deprecatedProvider = isDeprecated
|
||||
? modelProviders.find(p => p.provider === defaultModel.provider)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex h-8 items-center gap-0.5 rounded-lg p-1',
|
||||
isDisabled
|
||||
? 'bg-components-input-bg-disabled'
|
||||
: 'bg-components-input-bg-normal',
|
||||
!readonly && !isDisabled && 'cursor-pointer hover:bg-components-input-bg-hover',
|
||||
open && !isDisabled && 'bg-components-input-bg-hover',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isEmpty
|
||||
? (
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<ModelIcon
|
||||
className="p-0.5"
|
||||
provider={isSelected ? currentProvider : deprecatedProvider}
|
||||
modelName={isSelected ? currentModel.model : defaultModel?.model}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={cn('flex grow items-center gap-1 truncate px-1 py-[3px]', isDeprecated && deprecatedClassName)}>
|
||||
{isSelected && (
|
||||
<ModelName
|
||||
className="grow"
|
||||
modelItem={currentModel}
|
||||
showMode
|
||||
showFeatures
|
||||
/>
|
||||
)}
|
||||
{isDeprecated && (
|
||||
<div className="grow truncate text-components-input-text-filled system-sm-regular">
|
||||
{defaultModel.model}
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && (
|
||||
<div className="grow truncate text-[13px] text-text-quaternary">
|
||||
{t('detailPanel.configureModel', { ns: 'plugin' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelected && !readonly && !isActive && statusI18nKey && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
disabled={currentModel.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">
|
||||
{t('modelProvider.selector.incompatibleTip', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDeprecated && showDeprecatedWarnIcon && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={(
|
||||
<span className="i-ri-alert-line h-4 w-4 shrink-0 text-text-warning-secondary" />
|
||||
)}
|
||||
/>
|
||||
<TooltipContent placement="top">
|
||||
{t('modelProvider.deprecated', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!readonly && (isActive || isEmpty) && (
|
||||
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5 shrink-0 text-text-tertiary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelSelectorTrigger
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Model, ModelItem } from '../declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '../declarations'
|
||||
import ModelTrigger from './model-trigger'
|
||||
|
||||
vi.mock('../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useLanguage: () => 'en_US',
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../model-icon', () => ({
|
||||
default: ({ modelName }: { modelName: string }) => <span>{modelName}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../model-name', () => ({
|
||||
default: ({ modelItem }: { modelItem: ModelItem }) => <span>{modelItem.label.en_US}</span>,
|
||||
}))
|
||||
|
||||
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||
model: 'gpt-4',
|
||||
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
|
||||
model_type: ModelTypeEnum.textGeneration,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const makeModel = (overrides: Partial<Model> = {}): Model => ({
|
||||
provider: 'openai',
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
models: [makeModelItem()],
|
||||
status: ModelStatusEnum.active,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ModelTrigger', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should show model name', () => {
|
||||
render(
|
||||
<ModelTrigger
|
||||
open
|
||||
provider={makeModel()}
|
||||
model={makeModelItem()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show status tooltip content when model is not active', async () => {
|
||||
const { container } = render(
|
||||
<ModelTrigger
|
||||
open={false}
|
||||
provider={makeModel()}
|
||||
model={makeModelItem({ status: ModelStatusEnum.noConfigure })}
|
||||
/>,
|
||||
)
|
||||
|
||||
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', () => {
|
||||
render(
|
||||
<ModelTrigger
|
||||
open={false}
|
||||
provider={makeModel()}
|
||||
model={makeModelItem({ status: ModelStatusEnum.noConfigure })}
|
||||
readonly
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||
expect(screen.queryByText('No Configure')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { FC } from 'react'
|
||||
import type {
|
||||
Model,
|
||||
ModelItem,
|
||||
} from '../declarations'
|
||||
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'
|
||||
|
||||
type ModelTriggerProps = {
|
||||
open: boolean
|
||||
provider: Model
|
||||
model: ModelItem
|
||||
className?: string
|
||||
readonly?: boolean
|
||||
}
|
||||
const ModelTrigger: FC<ModelTriggerProps> = ({
|
||||
open,
|
||||
provider,
|
||||
model,
|
||||
className,
|
||||
readonly,
|
||||
}) => {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'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',
|
||||
model.status !== ModelStatusEnum.active && 'bg-components-input-bg-disabled hover:bg-components-input-bg-disabled',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ModelIcon
|
||||
className="p-0.5"
|
||||
provider={provider}
|
||||
modelName={model.model}
|
||||
/>
|
||||
<div className="flex grow items-center gap-1 truncate px-1 py-[3px]">
|
||||
<ModelName
|
||||
className="grow"
|
||||
modelItem={model}
|
||||
showMode
|
||||
showFeatures
|
||||
/>
|
||||
{!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>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelTrigger
|
||||
@@ -2,22 +2,21 @@ 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'
|
||||
|
||||
const mockUpdateModelList = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateModelProviders = vi.hoisted(() => vi.fn())
|
||||
const mockLanguageRef = vi.hoisted(() => ({ value: 'en_US' }))
|
||||
|
||||
vi.mock('../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useLanguage: () => 'en_US',
|
||||
useLanguage: () => mockLanguageRef.value,
|
||||
useUpdateModelList: () => mockUpdateModelList,
|
||||
useUpdateModelProviders: () => mockUpdateModelProviders,
|
||||
}
|
||||
@@ -35,30 +34,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 mockCredentialPanelState = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../provider-added-card/use-credential-panel-state', () => ({
|
||||
useCredentialPanelState: mockCredentialPanelState,
|
||||
}))
|
||||
|
||||
vi.mock('../provider-added-card/use-change-provider-priority', () => ({
|
||||
useChangeProviderPriority: () => ({
|
||||
isChangingPriority: false,
|
||||
handleChangePriority: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../provider-added-card/model-auth-dropdown/dropdown-content', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
const mockSetShowModelModal = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
@@ -71,11 +46,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' },
|
||||
@@ -97,52 +67,18 @@ 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()
|
||||
mockLanguageRef.value = 'en_US'
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [makeProvider()],
|
||||
modelProviders: [{ provider: 'openai' }],
|
||||
})
|
||||
mockUseAppContext.mockReturnValue({
|
||||
currentWorkspace: { trial_credits: 200, trial_credits_used: 0 },
|
||||
})
|
||||
mockCredentialPanelState.mockReturnValue({
|
||||
variant: 'api-active',
|
||||
priority: 'apiKey',
|
||||
supportsCredits: false,
|
||||
showPrioritySwitcher: false,
|
||||
hasCredentials: true,
|
||||
isCreditsExhausted: false,
|
||||
credentialName: 'my-api-key',
|
||||
credits: 200,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render nothing when provider is not found in modelProviders', () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [],
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />,
|
||||
)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should call onSelect when clicking an active model', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<PopupItem model={makeModel()} onSelect={onSelect} onHide={vi.fn()} />)
|
||||
render(<PopupItem model={makeModel()} onSelect={onSelect} />)
|
||||
|
||||
fireEvent.click(screen.getByText('GPT-4'))
|
||||
|
||||
@@ -155,7 +91,6 @@ describe('PopupItem', () => {
|
||||
<PopupItem
|
||||
model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.disabled })] })}
|
||||
onSelect={onSelect}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -169,7 +104,6 @@ describe('PopupItem', () => {
|
||||
<PopupItem
|
||||
model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.noConfigure })] })}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -189,7 +123,6 @@ describe('PopupItem', () => {
|
||||
models: [makeModelItem({ status: ModelStatusEnum.noConfigure, model_type: undefined as unknown as ModelTypeEnum })],
|
||||
})}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -208,102 +141,92 @@ describe('PopupItem', () => {
|
||||
defaultModel={defaultModel}
|
||||
model={makeModel()}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle collapsed state when clicking provider header', () => {
|
||||
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
|
||||
it('should not show check icon when model matches but provider does not', () => {
|
||||
const defaultModel: DefaultModel = { provider: 'anthropic', model: 'gpt-4' }
|
||||
render(
|
||||
<PopupItem
|
||||
defaultModel={defaultModel}
|
||||
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()
|
||||
const checkIcons = document.querySelectorAll('.h-4.w-4.shrink-0.text-text-accent')
|
||||
expect(checkIcons.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should show credential name when using custom provider', () => {
|
||||
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
|
||||
it('should not show mode badge when model_properties.mode is absent', () => {
|
||||
const modelItem = makeModelItem({ model_properties: {} })
|
||||
render(
|
||||
<PopupItem
|
||||
model={makeModel({ models: [modelItem] })}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('my-api-key')).toBeInTheDocument()
|
||||
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show configure required when no credential name', () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [makeProvider({
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.noConfigure,
|
||||
current_credential_name: '',
|
||||
},
|
||||
})],
|
||||
})
|
||||
mockCredentialPanelState.mockReturnValue({
|
||||
variant: 'api-required-configure',
|
||||
priority: 'apiKey',
|
||||
supportsCredits: false,
|
||||
showPrioritySwitcher: false,
|
||||
hasCredentials: false,
|
||||
isCreditsExhausted: false,
|
||||
credentialName: undefined,
|
||||
credits: 0,
|
||||
it('should fall back to en_US label when current locale translation is empty', () => {
|
||||
mockLanguageRef.value = 'zh_Hans'
|
||||
const model = makeModel({
|
||||
label: { en_US: 'English Label', zh_Hans: '' },
|
||||
})
|
||||
render(<PopupItem model={model} onSelect={vi.fn()} />)
|
||||
|
||||
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/modelProvider\.selector\.configureRequired/)).toBeInTheDocument()
|
||||
expect(screen.getByText('English Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show credits info when using system provider with remaining credits', () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [makeProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.system,
|
||||
})],
|
||||
})
|
||||
mockCredentialPanelState.mockReturnValue({
|
||||
variant: 'credits-active',
|
||||
priority: 'credits',
|
||||
supportsCredits: true,
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: false,
|
||||
isCreditsExhausted: false,
|
||||
credentialName: undefined,
|
||||
credits: 200,
|
||||
})
|
||||
it('should not show context_size badge when absent', () => {
|
||||
const modelItem = makeModelItem({ model_properties: { mode: 'chat' } })
|
||||
render(
|
||||
<PopupItem
|
||||
model={makeModel({ models: [modelItem] })}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/modelProvider\.selector\.aiCredits/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/K$/)).not.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 },
|
||||
})
|
||||
mockCredentialPanelState.mockReturnValue({
|
||||
variant: 'credits-exhausted',
|
||||
priority: 'credits',
|
||||
supportsCredits: true,
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: false,
|
||||
isCreditsExhausted: true,
|
||||
credentialName: undefined,
|
||||
credits: 0,
|
||||
})
|
||||
it('should not show capabilities section when features are empty', () => {
|
||||
const modelItem = makeModelItem({ features: [] })
|
||||
render(
|
||||
<PopupItem
|
||||
model={makeModel({ models: [modelItem] })}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
|
||||
expect(screen.queryByText('common.model.capabilities')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/)).toBeInTheDocument()
|
||||
it('should not show capabilities for non-qualifying model types', () => {
|
||||
const modelItem = makeModelItem({
|
||||
model_type: ModelTypeEnum.tts,
|
||||
features: [ModelFeatureEnum.vision],
|
||||
})
|
||||
render(
|
||||
<PopupItem
|
||||
model={makeModel({ models: [modelItem] })}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('common.model.capabilities')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show en_US label when language is fr_FR and fr_FR key is absent', () => {
|
||||
mockLanguageRef.value = 'fr_FR'
|
||||
const model = makeModel({ label: { en_US: 'FallbackLabel', zh_Hans: 'FallbackLabel' } })
|
||||
render(<PopupItem model={model} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('FallbackLabel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,15 +4,10 @@ import type {
|
||||
Model,
|
||||
ModelItem,
|
||||
} from '../declarations'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { Check } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -30,9 +25,6 @@ import {
|
||||
import ModelBadge from '../model-badge'
|
||||
import ModelIcon from '../model-icon'
|
||||
import ModelName from '../model-name'
|
||||
import DropdownContent from '../provider-added-card/model-auth-dropdown/dropdown-content'
|
||||
import { useChangeProviderPriority } from '../provider-added-card/use-change-provider-priority'
|
||||
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
|
||||
import {
|
||||
modelTypeFormat,
|
||||
sizeFormat,
|
||||
@@ -43,23 +35,19 @@ type PopupItemProps = {
|
||||
defaultModel?: DefaultModel
|
||||
model: Model
|
||||
onSelect: (provider: string, model: ModelItem) => void
|
||||
onHide: () => void
|
||||
}
|
||||
const PopupItem: FC<PopupItemProps> = ({
|
||||
defaultModel,
|
||||
model,
|
||||
onSelect,
|
||||
onHide,
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const { setShowModelModal } = useModalContext()
|
||||
const { modelProviders } = useProviderContext()
|
||||
const updateModelList = useUpdateModelList()
|
||||
const updateModelProviders = useUpdateModelProviders()
|
||||
const currentProvider = modelProviders.find(provider => provider.provider === model.provider)
|
||||
const currentProvider = modelProviders.find(provider => provider.provider === model.provider)!
|
||||
const handleSelect = (provider: string, modelItem: ModelItem) => {
|
||||
if (modelItem.status !== ModelStatusEnum.active)
|
||||
return
|
||||
@@ -67,8 +55,6 @@ const PopupItem: FC<PopupItemProps> = ({
|
||||
onSelect(provider, modelItem)
|
||||
}
|
||||
const handleOpenModelModal = () => {
|
||||
if (!currentProvider)
|
||||
return
|
||||
setShowModelModal({
|
||||
payload: {
|
||||
currentProvider,
|
||||
@@ -85,169 +71,101 @@ const PopupItem: FC<PopupItemProps> = ({
|
||||
})
|
||||
}
|
||||
|
||||
const state = useCredentialPanelState(currentProvider)
|
||||
const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(currentProvider)
|
||||
|
||||
const isUsingCredits = state.priority === 'credits'
|
||||
const hasCredits = !state.isCreditsExhausted
|
||||
const isApiKeyActive = state.variant === 'api-active' || state.variant === 'api-fallback'
|
||||
const { credentialName } = state
|
||||
|
||||
const handleCloseDropdown = useCallback(() => {
|
||||
setDropdownOpen(false)
|
||||
onHide()
|
||||
}, [onHide])
|
||||
|
||||
if (!currentProvider)
|
||||
return null
|
||||
|
||||
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>
|
||||
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button type="button" className="flex cursor-pointer items-center rounded-md px-1.5 py-1 text-text-tertiary system-xs-medium hover:bg-components-button-ghost-bg-hover">
|
||||
{isUsingCredits
|
||||
? (
|
||||
hasCredits
|
||||
? (
|
||||
<>
|
||||
<CreditsCoin className="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="i-ri-arrow-down-s-line !h-[14px] !w-[14px] translate-y-px text-text-tertiary" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent placement="bottom-end">
|
||||
<DropdownContent
|
||||
provider={currentProvider}
|
||||
state={state}
|
||||
isChangingPriority={isChangingPriority}
|
||||
onChangePriority={handleChangePriority}
|
||||
onClose={handleCloseDropdown}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<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}>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn('group relative flex h-8 w-full items-center gap-1 rounded-lg px-3 py-1.5 text-left', 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">
|
||||
{
|
||||
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}
|
||||
/>
|
||||
<ModelName
|
||||
className={cn('text-text-secondary system-sm-medium', modelItem.status !== ModelStatusEnum.active && 'opacity-60')}
|
||||
modelItem={modelItem}
|
||||
/>
|
||||
<div className="system-md-medium text-wrap break-words text-text-primary">{modelItem.label[language] || modelItem.label.en_US}</div>
|
||||
</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()}
|
||||
{/* {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>
|
||||
)
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent
|
||||
placement="right"
|
||||
variant="plain"
|
||||
popupClassName="w-[206px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<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>
|
||||
<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>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Tooltip>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Model, ModelItem } from '../declarations'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { tooltipManager } from '@/app/components/base/tooltip/TooltipManager'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelFeatureEnum,
|
||||
@@ -22,22 +23,11 @@ vi.mock('@/utils/tool-call', () => ({
|
||||
supportFunctionCall: mockSupportFunctionCall,
|
||||
}))
|
||||
|
||||
const mockCloseActiveTooltip = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/app/components/base/tooltip/TooltipManager', () => ({
|
||||
tooltipManager: {
|
||||
closeActiveTooltip: mockCloseActiveTooltip,
|
||||
register: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockMarketplacePlugins = vi.hoisted(() => ({ current: [] as Array<{ plugin_id: string, latest_package_identifier: string }> }))
|
||||
vi.mock('../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useLanguage: () => mockLanguage,
|
||||
useMarketplaceAllPlugins: () => ({ plugins: mockMarketplacePlugins.current }),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -45,63 +35,6 @@ vi.mock('./popup-item', () => ({
|
||||
default: ({ model }: { model: Model }) => <div>{model.provider}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({ modelProviders: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('../provider-added-card/use-trial-credits', () => ({
|
||||
useTrialCredits: () => ({
|
||||
credits: 200,
|
||||
totalCredits: 200,
|
||||
isExhausted: false,
|
||||
isLoading: false,
|
||||
nextCreditResetDate: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
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' },
|
||||
@@ -123,15 +56,17 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
|
||||
})
|
||||
|
||||
describe('Popup', () => {
|
||||
let closeActiveTooltipSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLanguage = 'en_US'
|
||||
mockSupportFunctionCall.mockReturnValue(true)
|
||||
mockMarketplacePlugins.current = []
|
||||
closeActiveTooltipSpy = vi.spyOn(tooltipManager, 'closeActiveTooltip')
|
||||
})
|
||||
|
||||
it('should filter models by search and allow clearing search', () => {
|
||||
const { container } = render(
|
||||
render(
|
||||
<Popup
|
||||
modelList={[makeModel()]}
|
||||
onSelect={vi.fn()}
|
||||
@@ -143,12 +78,11 @@ 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.change(input, { target: { value: '' } })
|
||||
expect((input as HTMLInputElement).value).toBe('')
|
||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter by scope features including toolCall and non-toolCall checks', () => {
|
||||
@@ -156,6 +90,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
|
||||
@@ -165,8 +100,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(
|
||||
@@ -190,6 +126,7 @@ describe('Popup', () => {
|
||||
)
|
||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||
|
||||
// When features are missing, non-toolCall feature checks should fail.
|
||||
unmount3()
|
||||
render(
|
||||
<Popup
|
||||
@@ -199,7 +136,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', () => {
|
||||
@@ -221,6 +158,24 @@ describe('Popup', () => {
|
||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter out model when features array exists but does not include required scopeFeature', () => {
|
||||
const modelWithToolCallOnly = makeModel({
|
||||
models: [makeModelItem({ features: [ModelFeatureEnum.toolCall] })],
|
||||
})
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[modelWithToolCallOnly]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
scopeFeatures={[ModelFeatureEnum.vision]}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The model item should be filtered out because it has toolCall but not vision
|
||||
expect(screen.queryByText('openai')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close tooltip on scroll', () => {
|
||||
const { container } = render(
|
||||
<Popup
|
||||
@@ -231,154 +186,53 @@ describe('Popup', () => {
|
||||
)
|
||||
|
||||
fireEvent.scroll(container.firstElementChild as HTMLElement)
|
||||
expect(mockCloseActiveTooltip).toHaveBeenCalled()
|
||||
expect(closeActiveTooltipSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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()
|
||||
it('should call onHide when footer settings link is clicked', () => {
|
||||
const mockOnHide = vi.fn()
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
modelList={[makeModel()]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={onHide}
|
||||
onHide={mockOnHide}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/modelProvider\.selector\.noProviderConfigured(?!Desc)/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/modelProvider\.selector\.noProviderConfiguredDesc/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('common.model.settingsLink'))
|
||||
|
||||
fireEvent.click(screen.getByText(/modelProvider\.selector\.configure/))
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
|
||||
payload: 'provider',
|
||||
})
|
||||
expect(mockOnHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render marketplace providers that are not installed', () => {
|
||||
it('should match model label when searchText is non-empty and label key exists for current language', () => {
|
||||
render(
|
||||
<Popup
|
||||
modelList={[makeModel({ provider: 'test-openai' })]}
|
||||
modelList={[makeModel()]}
|
||||
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()
|
||||
})
|
||||
// GPT-4 label has en_US key, so modelItem.label[language] is defined
|
||||
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
|
||||
fireEvent.change(input, { target: { value: 'gpt' } })
|
||||
|
||||
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()
|
||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,26 +4,19 @@ import type {
|
||||
Model,
|
||||
ModelItem,
|
||||
} from '../declarations'
|
||||
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useCallback, useMemo, 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 { IS_CLOUD_EDITION } from '@/config'
|
||||
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 { CustomConfigurationStatusEnum, ModelFeatureEnum } from '../declarations'
|
||||
import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
|
||||
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
|
||||
import { useTrialCredits } from '../provider-added-card/use-trial-credits'
|
||||
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils'
|
||||
import { ModelFeatureEnum } from '../declarations'
|
||||
import { useLanguage } from '../hooks'
|
||||
import PopupItem from './popup-item'
|
||||
|
||||
type PopupProps = {
|
||||
@@ -41,56 +34,32 @@ const Popup: FC<PopupProps> = ({
|
||||
onHide,
|
||||
}) => {
|
||||
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 {
|
||||
plugins: allPlugins,
|
||||
} = useMarketplaceAllPlugins(modelProviders, '')
|
||||
const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace()
|
||||
const { refreshPluginList } = useRefreshPluginList()
|
||||
const [installingProvider, setInstallingProvider] = useState<ModelProviderQuotaGetPaid | null>(null)
|
||||
const { isExhausted: isCreditsExhausted } = useTrialCredits()
|
||||
const showCreditsExhaustedAlert = useMemo(() => {
|
||||
return isCreditsExhausted && modelProviders.some(p => p.system_configuration.enabled && IS_CLOUD_EDITION)
|
||||
}, [isCreditsExhausted, modelProviders])
|
||||
const hasApiKeyFallback = useMemo(() => {
|
||||
return modelProviders.some((p) => {
|
||||
const isApiKeyActive = p.custom_configuration?.status === CustomConfigurationStatusEnum.active
|
||||
const supportsCredits = p.system_configuration.enabled && IS_CLOUD_EDITION
|
||||
return isApiKeyActive && supportsCredits
|
||||
})
|
||||
}, [modelProviders])
|
||||
const scrollRef = useRef<HTMLDivElement>(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)
|
||||
// Close any open tooltips when the user scrolls to prevent them from appearing
|
||||
// in incorrect positions or becoming detached from their trigger elements
|
||||
useEffect(() => {
|
||||
const handleTooltipCloseOnScroll = () => {
|
||||
tooltipManager.closeActiveTooltip()
|
||||
}
|
||||
|
||||
const scrollContainer = scrollRef.current
|
||||
if (!scrollContainer)
|
||||
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)
|
||||
// Use passive listener for better performance since we don't prevent default
|
||||
scrollContainer.addEventListener('scroll', handleTooltipCloseOnScroll, { passive: true })
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleTooltipCloseOnScroll)
|
||||
}
|
||||
catch { }
|
||||
finally {
|
||||
setInstallingProvider(null)
|
||||
}
|
||||
}, [allPlugins, installingProvider, installPackageFromMarketPlace, refreshPluginList])
|
||||
}, [])
|
||||
|
||||
const filteredModelList = useMemo(() => {
|
||||
const filtered = modelList.map((model) => {
|
||||
return modelList.map((model) => {
|
||||
const filteredModels = model.models
|
||||
.filter((modelItem) => {
|
||||
if (modelItem.label[language] !== undefined)
|
||||
@@ -110,34 +79,19 @@ const Popup: FC<PopupProps> = ({
|
||||
})
|
||||
return { ...model, models: filteredModels }
|
||||
}).filter(model => model.models.length > 0)
|
||||
|
||||
if (defaultModel?.provider) {
|
||||
filtered.sort((a, b) => {
|
||||
const aSelected = a.provider === defaultModel.provider ? 0 : 1
|
||||
const bSelected = b.provider === defaultModel.provider ? 0 : 1
|
||||
return aSelected - bSelected
|
||||
})
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [defaultModel?.provider, 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])
|
||||
}, [language, modelList, scopeFeatures, searchText])
|
||||
|
||||
return (
|
||||
<div className="max-h-[480px] overflow-y-auto no-scrollbar">
|
||||
<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'}
|
||||
`}
|
||||
/>
|
||||
@@ -149,17 +103,14 @@ 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('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{showCreditsExhaustedAlert && (
|
||||
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
|
||||
)}
|
||||
<div className="p-1">
|
||||
{
|
||||
filteredModelList.map(model => (
|
||||
@@ -168,112 +119,26 @@ const Popup: FC<PopupProps> = ({
|
||||
defaultModel={defaultModel}
|
||||
model={model}
|
||||
onSelect={onSelect}
|
||||
onHide={onHide}
|
||||
/>
|
||||
))
|
||||
}
|
||||
{!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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -1,54 +1,56 @@
|
||||
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 { ToastContext } from '@/app/components/base/toast/context'
|
||||
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', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
|
||||
return {
|
||||
...actual,
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: mockEventEmitter,
|
||||
}),
|
||||
}))
|
||||
|
||||
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('@/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 +58,161 @@ 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()
|
||||
})
|
||||
const renderCredentialPanel = (provider: ModelProvider) => render(
|
||||
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
|
||||
<CredentialPanel provider={provider} />
|
||||
</ToastContext.Provider>,
|
||||
)
|
||||
|
||||
it('should show warning icon for api-fallback variant', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
renderWithQueryClient(createProvider())
|
||||
expect(screen.getByTestId('warning-icon')).toBeInTheDocument()
|
||||
})
|
||||
it('should show credential name and configuration actions', () => {
|
||||
renderCredentialPanel(mockProvider)
|
||||
|
||||
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()
|
||||
})
|
||||
expect(screen.getByText('test-credential')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('config-provider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('priority-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT show warning icon for api-active variant', () => {
|
||||
renderWithQueryClient(createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
}))
|
||||
expect(screen.queryByTestId('warning-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
it('should show unauthorized status label when credential is missing', () => {
|
||||
mockCredentialStatus.hasCredential = false
|
||||
renderCredentialPanel(mockProvider)
|
||||
|
||||
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()
|
||||
expect(screen.getByText(/modelProvider\.auth\.unAuthorized/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show removed credential label and priority tip for custom preference', () => {
|
||||
mockCredentialStatus.authorized = false
|
||||
mockCredentialStatus.authRemoved = true
|
||||
renderCredentialPanel({ ...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' })
|
||||
renderCredentialPanel(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()
|
||||
})
|
||||
it('should render standalone priority selector without provider schema', () => {
|
||||
const providerNoSchema = {
|
||||
...mockProvider,
|
||||
provider_credential_schema: null,
|
||||
} as unknown as ModelProvider
|
||||
renderCredentialPanel(providerNoSchema)
|
||||
expect(screen.getByTestId('priority-selector')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('config-provider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
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 show gray indicator when notAllowedToUse is true', () => {
|
||||
mockCredentialStatus.notAllowedToUse = true
|
||||
renderCredentialPanel(mockProvider)
|
||||
|
||||
it('should use secondary text color for credits-active label', () => {
|
||||
const { container } = renderWithQueryClient(createProvider())
|
||||
expect(container.querySelector('.text-text-secondary')).toBeTruthy()
|
||||
})
|
||||
expect(screen.getByTestId('indicator')).toHaveTextContent('gray')
|
||||
})
|
||||
|
||||
describe('Priority change', () => {
|
||||
it('should call mutation with correct params on priority change', async () => {
|
||||
renderWithQueryClient(createProvider())
|
||||
it('should not notify or update when priority change returns non-success', async () => {
|
||||
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
|
||||
mockChangePriority.mockResolvedValue({ result: 'error' })
|
||||
renderCredentialPanel(mockProvider)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('change-priority-btn'))
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('priority-selector'))
|
||||
|
||||
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')
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(mockChangePriority).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
expect(mockUpdateModelProviders).not.toHaveBeenCalled()
|
||||
expect(mockEventEmitter.emit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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 show empty label when authorized is false and authRemoved is false', () => {
|
||||
mockCredentialStatus.authorized = false
|
||||
mockCredentialStatus.authRemoved = false
|
||||
renderCredentialPanel(mockProvider)
|
||||
|
||||
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')
|
||||
})
|
||||
expect(screen.queryByText(/modelProvider\.auth\.unAuthorized/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/modelProvider\.auth\.authRemoved/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
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 not show PriorityUseTip when priorityUseType is system', () => {
|
||||
renderCredentialPanel(mockProvider)
|
||||
|
||||
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')
|
||||
expect(screen.queryByTestId('priority-use-tip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not iterate configurateMethods for non-predefinedModel methods', async () => {
|
||||
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
|
||||
mockChangePriority.mockResolvedValue({ result: 'success' })
|
||||
const providerWithCustomMethod = {
|
||||
...mockProvider,
|
||||
configurate_methods: [ConfigurationMethodEnum.customizableModel],
|
||||
} as unknown as ModelProvider
|
||||
renderCredentialPanel(providerWithCustomMethod)
|
||||
|
||||
fireEvent.click(screen.getByTestId('priority-selector'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChangePriority).toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockUpdateModelList).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show red indicator when hasCredential is false', () => {
|
||||
mockCredentialStatus.hasCredential = false
|
||||
renderCredentialPanel(mockProvider)
|
||||
|
||||
expect(screen.getByTestId('indicator')).toHaveTextContent('red')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,110 +1,151 @@
|
||||
import type { ModelProvider } from '../declarations'
|
||||
import type { CardVariant } from './use-credential-panel-state'
|
||||
import { memo } from 'react'
|
||||
import type {
|
||||
ModelProvider,
|
||||
} from '../declarations'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Warning from '@/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning'
|
||||
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 ModelAuthDropdown from './model-auth-dropdown'
|
||||
import SystemQuotaCard from './system-quota-card'
|
||||
import { useChangeProviderPriority } from './use-change-provider-priority'
|
||||
import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state'
|
||||
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 { 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 state = useCredentialPanelState(provider)
|
||||
const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(provider)
|
||||
|
||||
const { variant, credentialName } = state
|
||||
const isDestructive = isDestructiveVariant(variant)
|
||||
const isTextLabel = TEXT_LABEL_VARIANTS.has(variant)
|
||||
const needsGap = !isTextLabel || variant === 'credits-fallback'
|
||||
|
||||
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 { notify } = useToastContext()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const updateModelList = useUpdateModelList()
|
||||
const updateModelProviders = useUpdateModelProviders()
|
||||
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 showPrioritySelector = systemConfig.enabled && isCustomConfigured && IS_CLOUD_EDITION
|
||||
|
||||
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' })
|
||||
|
||||
return ''
|
||||
}, [authorized, authRemoved, current_credential_name, hasCredential])
|
||||
|
||||
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
|
||||
|
||||
@@ -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,25 +125,46 @@ 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)
|
||||
it('should show loading spinner while model list is being fetched', async () => {
|
||||
let resolvePromise: (value: unknown) => void = () => {}
|
||||
const pendingPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
vi.mocked(fetchModelProviderModelList).mockReturnValue(pendingPromise as ReturnType<typeof fetchModelProviderModelList>)
|
||||
|
||||
fireEvent.click(screen.getByTestId('expand-current-provider'))
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModelProviderModels).toHaveBeenCalledWith(mockProvider.provider)
|
||||
render(<ProviderAddedCard provider={mockProvider} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('show-models-button'))
|
||||
|
||||
expect(document.querySelector('.i-ri-loader-2-line.animate-spin')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
resolvePromise({ data: [] })
|
||||
})
|
||||
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show modelsNum text after models have loaded', async () => {
|
||||
const models = [
|
||||
{ model: 'gpt-4' },
|
||||
{ model: 'gpt-3.5' },
|
||||
]
|
||||
vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: models } as unknown as { data: ModelItem[] })
|
||||
|
||||
render(<ProviderAddedCard provider={mockProvider} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('show-models-button'))
|
||||
|
||||
await screen.findByTestId('model-list')
|
||||
|
||||
const collapseBtn = screen.getByRole('button', { name: 'collapse list' })
|
||||
fireEvent.click(collapseBtn)
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('model-list')).not.toBeInTheDocument())
|
||||
|
||||
const numTexts = screen.getAllByText(/modelProvider\.modelsNum/)
|
||||
expect(numTexts.length).toBeGreaterThan(0)
|
||||
|
||||
expect(screen.getByText(/modelProvider\.showModelsNum/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configure tip when provider is not in quota list and not configured', () => {
|
||||
@@ -192,23 +172,93 @@ describe('ProviderAddedCard', () => {
|
||||
...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 apply anthropic background class for anthropic provider', () => {
|
||||
const anthropicProvider = {
|
||||
...mockProvider,
|
||||
provider: 'langgenius/anthropic/anthropic',
|
||||
} as unknown as ModelProvider
|
||||
const { container } = render(<ProviderAddedCard provider={anthropicProvider} />)
|
||||
|
||||
expect(container.querySelector('.bg-third-party-model-bg-anthropic')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('should render credential panel when showCredential is true', () => {
|
||||
// Arrange: use ConfigurationMethodEnum.predefinedModel ('predefined-model') so showCredential=true
|
||||
const predefinedProvider = {
|
||||
...mockProvider,
|
||||
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||
} as unknown as ModelProvider
|
||||
|
||||
mockIsCurrentWorkspaceManager = true
|
||||
|
||||
// Act
|
||||
render(<ProviderAddedCard provider={predefinedProvider} />)
|
||||
|
||||
// Assert: credential-panel is rendered (showCredential = true branch)
|
||||
expect(screen.getByTestId('credential-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render credential panel when user is not workspace manager', () => {
|
||||
// Arrange: predefined-model but manager=false so showCredential=false
|
||||
const predefinedProvider = {
|
||||
...mockProvider,
|
||||
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||
} as unknown as ModelProvider
|
||||
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
|
||||
// Act
|
||||
render(<ProviderAddedCard provider={predefinedProvider} />)
|
||||
|
||||
// Assert: credential-panel is not rendered (showCredential = false)
|
||||
expect(screen.queryByTestId('credential-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
let mockPlanType: string = 'pro'
|
||||
|
||||
@@ -79,7 +71,6 @@ describe('ModelListItem', () => {
|
||||
provider={mockProvider}
|
||||
isConfigurable={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-name')).toBeInTheDocument()
|
||||
@@ -94,7 +85,6 @@ describe('ModelListItem', () => {
|
||||
isConfigurable={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
@@ -114,7 +104,6 @@ describe('ModelListItem', () => {
|
||||
isConfigurable={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
@@ -135,7 +124,6 @@ describe('ModelListItem', () => {
|
||||
isConfigurable={false}
|
||||
onModifyLoadBalancing={onModifyLoadBalancing}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'modify load balancing' }))
|
||||
@@ -155,7 +143,6 @@ describe('ModelListItem', () => {
|
||||
provider={mockProvider}
|
||||
isConfigurable={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Assert
|
||||
@@ -181,7 +168,6 @@ describe('ModelListItem', () => {
|
||||
provider={mockProvider}
|
||||
isConfigurable={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Assert - Badge component should render
|
||||
@@ -202,7 +188,6 @@ describe('ModelListItem', () => {
|
||||
provider={mockProvider}
|
||||
isConfigurable={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Assert - ConfigModel should show because plan.type === 'sandbox'
|
||||
@@ -222,7 +207,6 @@ describe('ModelListItem', () => {
|
||||
provider={mockProvider}
|
||||
isConfigurable={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Assert - ConfigModel should NOT show because plan.type !== 'sandbox' and load balancing is disabled
|
||||
@@ -242,7 +226,6 @@ describe('ModelListItem', () => {
|
||||
provider={mockProvider}
|
||||
isConfigurable={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Assert - ConfigModel should not render because status is not active/disabled
|
||||
@@ -264,7 +247,6 @@ describe('ModelListItem', () => {
|
||||
provider={mockProvider}
|
||||
isConfigurable={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user