mirror of
https://github.com/langgenius/dify.git
synced 2026-03-11 01:57:06 +00:00
Compare commits
71 Commits
fix/vinext
...
feat/model
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c08c4016d | ||
|
|
948efa129f | ||
|
|
6d612c0909 | ||
|
|
56e0dc0ae6 | ||
|
|
975eca00c3 | ||
|
|
f049bafcc3 | ||
|
|
922dc71e36 | ||
|
|
f03ec7f671 | ||
|
|
29f275442d | ||
|
|
c9532ffd43 | ||
|
|
840dc33b8b | ||
|
|
cae58a0649 | ||
|
|
1752edc047 | ||
|
|
7471c32612 | ||
|
|
2d333bbbe5 | ||
|
|
4af6788ce0 | ||
|
|
24b072def9 | ||
|
|
909c8c3350 | ||
|
|
80e9c8bee0 | ||
|
|
15b7b304d2 | ||
|
|
61e2672b59 | ||
|
|
5f4ed4c6f6 | ||
|
|
4a1032c628 | ||
|
|
423c97a47e | ||
|
|
a7e3fb2e33 | ||
|
|
ce34937a1c | ||
|
|
ad9ac6978e | ||
|
|
57c1ba3543 | ||
|
|
d7a5af2b9a | ||
|
|
d45edffaa3 | ||
|
|
530515b6ef | ||
|
|
f13f0d1f9a | ||
|
|
b597d52c11 | ||
|
|
34c42fe666 | ||
|
|
dc109c99f0 | ||
|
|
223b9d89c1 | ||
|
|
dd119eb44f | ||
|
|
970493fa85 | ||
|
|
ab87ac333a | ||
|
|
b8b70da9ad | ||
|
|
77d81aebe8 | ||
|
|
deb4cd3ece | ||
|
|
648d9ef1f9 | ||
|
|
5ed4797078 | ||
|
|
62631658e9 | ||
|
|
22a4100dd7 | ||
|
|
0f7ed6f67e | ||
|
|
4d9fcbec57 | ||
|
|
4d7a9bc798 | ||
|
|
d6d04ed657 | ||
|
|
f594a71dae | ||
|
|
04e0ab7eda | ||
|
|
784bda9c86 | ||
|
|
1af1fb6913 | ||
|
|
1f0c36e9f7 | ||
|
|
455ae65025 | ||
|
|
d44682e957 | ||
|
|
8c4afc0c18 | ||
|
|
539cbcae6a | ||
|
|
8d257fea7c | ||
|
|
c3364ac350 | ||
|
|
f991644989 | ||
|
|
29e344ac8b | ||
|
|
1ad9305732 | ||
|
|
17f38f171d | ||
|
|
802088c8eb | ||
|
|
cad6d94491 | ||
|
|
621d0fb2c9 | ||
|
|
a92fb3244b | ||
|
|
97508f8d7b | ||
|
|
70e677a6ac |
@@ -295,24 +295,7 @@ describe('Pricing Modal Flow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 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 ─────────────────────────────────────────────────────
|
||||
// ─── 6. Pricing URL ─────────────────────────────────────────────────────
|
||||
describe('Pricing page URL', () => {
|
||||
it('should render pricing link with correct URL', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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,5 +1,6 @@
|
||||
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'
|
||||
|
||||
@@ -43,20 +43,24 @@ type DialogContentProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
overlayClassName?: string
|
||||
backdropProps?: React.ComponentPropsWithoutRef<typeof BaseDialog.Backdrop>
|
||||
}
|
||||
|
||||
export function DialogContent({
|
||||
children,
|
||||
className,
|
||||
overlayClassName,
|
||||
backdropProps,
|
||||
}: DialogContentProps) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<BaseDialog.Backdrop
|
||||
{...backdropProps}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-background-overlay',
|
||||
'transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
|
||||
overlayClassName,
|
||||
backdropProps?.className,
|
||||
)}
|
||||
/>
|
||||
<BaseDialog.Popup
|
||||
|
||||
@@ -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,7 +1,16 @@
|
||||
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()
|
||||
@@ -11,7 +20,7 @@ describe('Header', () => {
|
||||
it('should render title and description translations', () => {
|
||||
const handleClose = vi.fn()
|
||||
|
||||
render(<Header onClose={handleClose} />)
|
||||
renderHeader(handleClose)
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument()
|
||||
@@ -22,7 +31,7 @@ describe('Header', () => {
|
||||
describe('Props', () => {
|
||||
it('should invoke onClose when close button is clicked', () => {
|
||||
const handleClose = vi.fn()
|
||||
render(<Header onClose={handleClose} />)
|
||||
renderHeader(handleClose)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
@@ -32,7 +41,7 @@ describe('Header', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render structural elements with translation keys', () => {
|
||||
const { container } = render(<Header onClose={vi.fn()} />)
|
||||
const { container } = renderHeader(vi.fn())
|
||||
|
||||
expect(container.querySelector('span')).toBeInTheDocument()
|
||||
expect(container.querySelector('p')).toBeInTheDocument()
|
||||
|
||||
@@ -74,15 +74,11 @@ describe('Pricing', () => {
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should allow switching categories and handle esc key', () => {
|
||||
const handleCancel = vi.fn()
|
||||
render(<Pricing onCancel={handleCancel} />)
|
||||
it('should allow switching categories', () => {
|
||||
render(<Pricing onCancel={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByText('billing.plansCommon.self'))
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
||||
expect(handleCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { Category } from '.'
|
||||
import { RiArrowRightUpLine } from '@remixicon/react'
|
||||
import type { Category } from './types'
|
||||
import Link from 'next/link'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { CategoryEnum } from '.'
|
||||
import { CategoryEnum } from './types'
|
||||
|
||||
type FooterProps = {
|
||||
pricingPageURL: string
|
||||
@@ -34,7 +33,7 @@ const Footer = ({
|
||||
>
|
||||
{t('plansCommon.comparePlanAndFeatures', { ns: 'billing' })}
|
||||
</Link>
|
||||
<RiArrowRightUpLine className="size-4" />
|
||||
<span aria-hidden="true" className="i-ri-arrow-right-up-line 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>
|
||||
<span className="bg-billing-plan-title-bg bg-clip-text px-1.5 font-instrument text-[37px] italic leading-[1.2] text-transparent">
|
||||
<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">
|
||||
{t('plansCommon.title.plans', { ns: 'billing' })}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<p className="system-sm-regular text-text-tertiary">
|
||||
<DialogDescription className="m-0 text-text-tertiary system-sm-regular">
|
||||
{t('plansCommon.title.description', { ns: 'billing' })}
|
||||
</p>
|
||||
</DialogDescription>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="absolute bottom-[40.5px] right-[-18px] z-10 size-9 rounded-full p-2"
|
||||
onClick={onClose}
|
||||
>
|
||||
<RiCloseLine className="size-5" />
|
||||
<span aria-hidden="true" className="i-ri-close-line size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import type { Category } from './types'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGetPricingPageLanguage } from '@/context/i18n'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
@@ -13,13 +13,7 @@ import Header from './header'
|
||||
import PlanSwitcher from './plan-switcher'
|
||||
import { PlanRange } from './plan-switcher/plan-range-switcher'
|
||||
import Plans from './plans'
|
||||
|
||||
export enum CategoryEnum {
|
||||
CLOUD = 'cloud',
|
||||
SELF = 'self',
|
||||
}
|
||||
|
||||
export type Category = CategoryEnum.CLOUD | CategoryEnum.SELF
|
||||
import { CategoryEnum } from './types'
|
||||
|
||||
type PricingProps = {
|
||||
onCancel: () => void
|
||||
@@ -33,42 +27,47 @@ 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 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()}
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<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 />
|
||||
<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>
|
||||
<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,
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
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 '../../index'
|
||||
import { CategoryEnum } from '../../types'
|
||||
import PlanSwitcher from '../index'
|
||||
import { PlanRange } from '../plan-range-switcher'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Category } from '../index'
|
||||
import type { Category } from '../types'
|
||||
import type { PlanRange } from './plan-range-switcher'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
6
web/app/components/billing/pricing/types.ts
Normal file
6
web/app/components/billing/pricing/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum CategoryEnum {
|
||||
CLOUD = 'cloud',
|
||||
SELF = 'self',
|
||||
}
|
||||
|
||||
export type Category = CategoryEnum.CLOUD | CategoryEnum.SELF
|
||||
@@ -100,10 +100,10 @@ vi.mock('@/app/components/datasets/create/step-two', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting', () => ({
|
||||
default: ({ activeTab, onCancel }: { activeTab?: string, onCancel?: () => void }) => (
|
||||
default: ({ activeTab, onCancelAction }: { activeTab?: string, onCancelAction?: () => void }) => (
|
||||
<div data-testid="account-setting">
|
||||
<span data-testid="active-tab">{activeTab}</span>
|
||||
<button onClick={onCancel} data-testid="close-setting">Close</button>
|
||||
<button onClick={onCancelAction} data-testid="close-setting">Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
|
||||
import type { DataSourceProvider, NotionPage } from '@/models/common'
|
||||
import type {
|
||||
CrawlOptions,
|
||||
@@ -19,6 +20,7 @@ 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'
|
||||
@@ -33,8 +35,13 @@ 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()
|
||||
@@ -135,7 +142,7 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
|
||||
{dataset && documentDetail && (
|
||||
<StepTwo
|
||||
isAPIKeySet={!!embeddingsDefaultModel}
|
||||
onSetting={showSetAPIKey}
|
||||
onSetting={handleOpenAccountSetting}
|
||||
datasetId={datasetId}
|
||||
dataSourceType={documentDetail.data_source_type as DataSourceType}
|
||||
notionPages={currentPage ? [currentPage as unknown as NotionPage] : []}
|
||||
@@ -155,8 +162,9 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
|
||||
</div>
|
||||
{isShowSetAPIKey && (
|
||||
<AccountSetting
|
||||
activeTab="provider"
|
||||
onCancel={async () => {
|
||||
activeTab={accountSettingTab}
|
||||
onTabChangeAction={setAccountSettingTab}
|
||||
onCancelAction={async () => {
|
||||
hideSetAPIkey()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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 {
|
||||
@@ -47,10 +51,15 @@ 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: [] } })),
|
||||
}))
|
||||
@@ -105,6 +114,38 @@ 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()
|
||||
@@ -120,11 +161,7 @@ describe('AccountSetting', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render the sidebar with correct menu items', () => {
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.userProfile.settings')).toBeInTheDocument()
|
||||
@@ -137,13 +174,9 @@ describe('AccountSetting', () => {
|
||||
expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should respect the activeTab prop', () => {
|
||||
it('should respect the initial tab', () => {
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} activeTab={ACCOUNT_SETTING_TAB.DATA_SOURCE} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.DATA_SOURCE })
|
||||
|
||||
// Assert
|
||||
// Check that the active item title is Data Source
|
||||
@@ -157,11 +190,7 @@ describe('AccountSetting', () => {
|
||||
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
|
||||
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting()
|
||||
|
||||
// Assert
|
||||
// On mobile, the labels should not be rendered as per the implementation
|
||||
@@ -176,11 +205,7 @@ describe('AccountSetting', () => {
|
||||
})
|
||||
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting()
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
|
||||
@@ -197,11 +222,7 @@ describe('AccountSetting', () => {
|
||||
})
|
||||
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting()
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('common.settings.billing')).not.toBeInTheDocument()
|
||||
@@ -212,11 +233,7 @@ describe('AccountSetting', () => {
|
||||
describe('Tab Navigation', () => {
|
||||
it('should change active tab when clicking on menu item', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} onTabChange={mockOnTabChange} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting({ onTabChange: mockOnTabChange })
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('common.settings.provider'))
|
||||
@@ -229,11 +246,7 @@ describe('AccountSetting', () => {
|
||||
|
||||
it('should navigate through various tabs and show correct details', () => {
|
||||
// Act & Assert
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting()
|
||||
|
||||
// Billing
|
||||
fireEvent.click(screen.getByText('common.settings.billing'))
|
||||
@@ -267,13 +280,11 @@ describe('AccountSetting', () => {
|
||||
describe('Interactions', () => {
|
||||
it('should call onCancel when clicking close button', () => {
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0])
|
||||
renderAccountSetting()
|
||||
const closeIcon = document.querySelector('.i-ri-close-line')
|
||||
const closeButton = closeIcon?.closest('button')
|
||||
expect(closeButton).not.toBeNull()
|
||||
fireEvent.click(closeButton!)
|
||||
|
||||
// Assert
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
@@ -281,11 +292,7 @@ describe('AccountSetting', () => {
|
||||
|
||||
it('should call onCancel when pressing Escape key', () => {
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
// Assert
|
||||
@@ -294,12 +301,7 @@ describe('AccountSetting', () => {
|
||||
|
||||
it('should update search value in provider tab', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('common.settings.provider'))
|
||||
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.PROVIDER })
|
||||
|
||||
// Act
|
||||
const input = screen.getByRole('textbox')
|
||||
@@ -312,11 +314,7 @@ describe('AccountSetting', () => {
|
||||
|
||||
it('should handle scroll event in panel', () => {
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting()
|
||||
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 { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, 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,15 +20,16 @@ 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 = {
|
||||
onCancel: () => void
|
||||
activeTab?: AccountSettingTab
|
||||
onTabChange?: (tab: AccountSettingTab) => void
|
||||
onCancelAction: () => void
|
||||
activeTab: AccountSettingTab
|
||||
onTabChangeAction: (tab: AccountSettingTab) => void
|
||||
}
|
||||
|
||||
type GroupItem = {
|
||||
@@ -40,14 +41,12 @@ type GroupItem = {
|
||||
}
|
||||
|
||||
export default function AccountSetting({
|
||||
onCancel,
|
||||
activeTab = ACCOUNT_SETTING_TAB.MEMBERS,
|
||||
onTabChange,
|
||||
onCancelAction,
|
||||
activeTab,
|
||||
onTabChangeAction,
|
||||
}: IAccountSettingProps) {
|
||||
const [activeMenu, setActiveMenu] = useState<AccountSettingTab>(activeTab)
|
||||
useEffect(() => {
|
||||
setActiveMenu(activeTab)
|
||||
}, [activeTab])
|
||||
const resetModelProviderListExpanded = useResetModelProviderListExpanded()
|
||||
const activeMenu = activeTab
|
||||
const { t } = useTranslation()
|
||||
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
|
||||
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
@@ -148,10 +147,22 @@ 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={onCancel}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<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]">
|
||||
@@ -166,21 +177,22 @@ export default function AccountSetting({
|
||||
<div>
|
||||
{
|
||||
menuItem.items.map(item => (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={item.key}
|
||||
className={cn(
|
||||
'mb-0.5 flex h-[37px] cursor-pointer items-center rounded-lg p-1 pl-3 text-sm',
|
||||
'mb-0.5 flex h-[37px] w-full items-center rounded-lg p-1 pl-3 text-left 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={() => {
|
||||
setActiveMenu(item.key)
|
||||
onTabChange?.(item.key)
|
||||
handleTabChange(item.key)
|
||||
}}
|
||||
>
|
||||
{activeMenu === item.key ? item.activeIcon : item.icon}
|
||||
{!isMobile && <div className="truncate">{item.name}</div>}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@@ -195,7 +207,8 @@ export default function AccountSetting({
|
||||
variant="tertiary"
|
||||
size="large"
|
||||
className="px-2"
|
||||
onClick={onCancel}
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<span className="i-ri-close-line h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
@@ -40,8 +40,7 @@ describe('MenuDialog', () => {
|
||||
)
|
||||
|
||||
// Assert
|
||||
const panel = screen.getByRole('dialog').querySelector('.custom-class')
|
||||
expect(panel).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog')).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { Fragment, useCallback, useEffect } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type DialogProps = {
|
||||
@@ -19,42 +18,25 @@ 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 (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
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,6 +9,7 @@ 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,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
useAnthropicBuyQuota,
|
||||
useCurrentProviderAndModel,
|
||||
useDefaultModel,
|
||||
useInvalidateDefaultModel,
|
||||
useLanguage,
|
||||
useMarketplaceAllPlugins,
|
||||
useModelList,
|
||||
@@ -36,7 +38,6 @@ import {
|
||||
useUpdateModelList,
|
||||
useUpdateModelProviders,
|
||||
} from './hooks'
|
||||
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './provider-added-card'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
@@ -78,14 +79,6 @@ 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: [],
|
||||
@@ -99,12 +92,16 @@ 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(() => {
|
||||
@@ -864,6 +861,38 @@ 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', {
|
||||
@@ -1167,39 +1196,52 @@ 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 emit event when refreshModelList is true and custom config is active', () => {
|
||||
it('should expand target provider list when refreshModelList is true and custom config is active', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
const emit = vi.fn()
|
||||
const expandModelProviderList = vi.fn()
|
||||
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
; (useEventEmitterContextContext as Mock).mockReturnValue({
|
||||
eventEmitter: { emit },
|
||||
})
|
||||
; (useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList)
|
||||
|
||||
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())
|
||||
|
||||
@@ -1207,23 +1249,30 @@ describe('hooks', () => {
|
||||
result.current.handleRefreshModel(provider, customFields, true)
|
||||
})
|
||||
|
||||
expect(emit).toHaveBeenCalledWith({
|
||||
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
|
||||
payload: 'openai',
|
||||
expect(expandModelProviderList).toHaveBeenCalledWith('openai')
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: modelProviderModelListQueryKey,
|
||||
exact: true,
|
||||
refetchType: 'active',
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textGeneration] })
|
||||
})
|
||||
|
||||
it('should not emit event when custom config is not active', () => {
|
||||
it('should not expand provider list when custom config is not active', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
const emit = vi.fn()
|
||||
const expandModelProviderList = vi.fn()
|
||||
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
; (useEventEmitterContextContext as Mock).mockReturnValue({
|
||||
eventEmitter: { emit },
|
||||
})
|
||||
; (useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList)
|
||||
|
||||
const provider = { ...createMockProvider(), custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure } }
|
||||
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
provider: provider.provider,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useRefreshModel())
|
||||
|
||||
@@ -1231,16 +1280,43 @@ describe('hooks', () => {
|
||||
result.current.handleRefreshModel(provider, undefined, true)
|
||||
})
|
||||
|
||||
expect(emit).not.toHaveBeenCalled()
|
||||
expect(expandModelProviderList).not.toHaveBeenCalled()
|
||||
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
||||
queryKey: modelProviderModelListQueryKey,
|
||||
exact: true,
|
||||
refetchType: 'active',
|
||||
})
|
||||
})
|
||||
|
||||
it('should refetch active model provider list when custom refresh callback is absent', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
|
||||
const provider = createMockProvider()
|
||||
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
provider: provider.provider,
|
||||
},
|
||||
},
|
||||
})
|
||||
const { result } = renderHook(() => useRefreshModel())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshModel(provider, undefined, true)
|
||||
})
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: modelProviderModelListQueryKey,
|
||||
exact: true,
|
||||
refetchType: 'active',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle provider with single model type', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
; (useEventEmitterContextContext as Mock).mockReturnValue({
|
||||
eventEmitter: { emit: vi.fn() },
|
||||
})
|
||||
|
||||
const provider = {
|
||||
...createMockProvider(),
|
||||
|
||||
@@ -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,15 +57,21 @@ export const useSystemDefaultModelAndModelList: UseDefaultModelAndModelList = (
|
||||
|
||||
return currentDefaultModel
|
||||
}, [defaultModel, modelList])
|
||||
const currentDefaultModelKey = currentDefaultModel
|
||||
? `${currentDefaultModel.provider}:${currentDefaultModel.model}`
|
||||
: ''
|
||||
const [defaultModelState, setDefaultModelState] = useState<DefaultModel | undefined>(currentDefaultModel)
|
||||
const handleDefaultModelChange = useCallback((model: DefaultModel) => {
|
||||
setDefaultModelState(model)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
setDefaultModelState(currentDefaultModel)
|
||||
}, [currentDefaultModel])
|
||||
const [defaultModelSourceKey, setDefaultModelSourceKey] = useState(currentDefaultModelKey)
|
||||
const selectedDefaultModel = defaultModelSourceKey === currentDefaultModelKey
|
||||
? defaultModelState
|
||||
: currentDefaultModel
|
||||
|
||||
return [defaultModelState, handleDefaultModelChange]
|
||||
const handleDefaultModelChange = useCallback((model: DefaultModel) => {
|
||||
setDefaultModelSourceKey(currentDefaultModelKey)
|
||||
setDefaultModelState(model)
|
||||
}, [currentDefaultModelKey])
|
||||
|
||||
return [selectedDefaultModel, handleDefaultModelChange]
|
||||
}
|
||||
|
||||
export const useLanguage = () => {
|
||||
@@ -116,7 +122,7 @@ export const useProviderCredentialsAndLoadBalancing = (
|
||||
predefinedFormSchemasValue?.credentials,
|
||||
])
|
||||
|
||||
const mutate = useMemo(() => () => {
|
||||
const mutate = useCallback(() => {
|
||||
if (predefinedEnabled)
|
||||
queryClient.invalidateQueries({ queryKey: ['model-providers', 'credentials', provider, credentialId] })
|
||||
if (customEnabled)
|
||||
@@ -222,6 +228,14 @@ 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)
|
||||
|
||||
@@ -314,7 +328,8 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText:
|
||||
}
|
||||
|
||||
export const useRefreshModel = () => {
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const expandModelProviderList = useExpandModelProviderList()
|
||||
const queryClient = useQueryClient()
|
||||
const updateModelProviders = useUpdateModelProviders()
|
||||
const updateModelList = useUpdateModelList()
|
||||
const handleRefreshModel = useCallback((
|
||||
@@ -322,6 +337,19 @@ 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) => {
|
||||
@@ -329,15 +357,17 @@ export const useRefreshModel = () => {
|
||||
})
|
||||
|
||||
if (refreshModelList && provider.custom_configuration.status === CustomConfigurationStatusEnum.active) {
|
||||
eventEmitter?.emit({
|
||||
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
|
||||
payload: provider.provider,
|
||||
} as any)
|
||||
expandModelProviderList(provider.provider)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: modelProviderModelListQueryKey,
|
||||
exact: true,
|
||||
refetchType: 'active',
|
||||
})
|
||||
|
||||
if (CustomConfigurationModelFixedFields?.__model_type)
|
||||
updateModelList(CustomConfigurationModelFixedFields.__model_type)
|
||||
}
|
||||
}, [eventEmitter, updateModelList, updateModelProviders])
|
||||
}, [expandModelProviderList, queryClient, updateModelList, updateModelProviders])
|
||||
|
||||
return {
|
||||
handleRefreshModel,
|
||||
|
||||
@@ -7,16 +7,7 @@ import {
|
||||
} from './declarations'
|
||||
import ModelProviderPage from './index'
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
isValidatingCurrentWorkspace: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockGlobalState = {
|
||||
systemFeatures: { enable_marketplace: true },
|
||||
}
|
||||
let mockEnableMarketplace = true
|
||||
|
||||
const mockQuotaConfig = {
|
||||
quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
@@ -28,7 +19,11 @@ const mockQuotaConfig = {
|
||||
}
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (s: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector(mockGlobalState),
|
||||
useSystemFeaturesQuery: () => ({
|
||||
data: {
|
||||
enable_marketplace: mockEnableMarketplace,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockProviders = [
|
||||
@@ -60,13 +55,16 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockDefaultModelState = {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
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 },
|
||||
}
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
useDefaultModel: () => mockDefaultModelState,
|
||||
useDefaultModel: (type: string) => mockDefaultModels[type] ?? { data: null, isLoading: false },
|
||||
}))
|
||||
|
||||
vi.mock('./install-from-marketplace', () => ({
|
||||
@@ -85,13 +83,18 @@ 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()
|
||||
mockGlobalState.systemFeatures.enable_marketplace = true
|
||||
mockDefaultModelState.data = null
|
||||
mockDefaultModelState.isLoading = false
|
||||
mockEnableMarketplace = true
|
||||
Object.keys(mockDefaultModels).forEach((key) => {
|
||||
mockDefaultModels[key] = { data: null, isLoading: false }
|
||||
})
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI' },
|
||||
@@ -149,13 +152,76 @@ describe('ModelProviderPage', () => {
|
||||
})
|
||||
|
||||
it('should hide marketplace section when marketplace feature is disabled', () => {
|
||||
mockGlobalState.systemFeatures.enable_marketplace = false
|
||||
mockEnableMarketplace = 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',
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import type {
|
||||
ModelProvider,
|
||||
} from './declarations'
|
||||
import {
|
||||
RiAlertFill,
|
||||
RiBrainLine,
|
||||
} from '@remixicon/react'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useSystemFeaturesQuery } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useCheckInstalled } from '@/service/use-plugins'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
CustomConfigurationStatusEnum,
|
||||
@@ -24,6 +21,9 @@ 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,20 +34,35 @@ 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 { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
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 isDefaultModelLoading = isTextGenerationDefaultModelLoading
|
||||
|| isEmbeddingsDefaultModelLoading
|
||||
|| isRerankDefaultModelLoading
|
||||
|| isSpeech2textDefaultModelLoading
|
||||
|| isTTSDefaultModelLoading
|
||||
const defaultModelNotConfigured = !isDefaultModelLoading && !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel
|
||||
const [configuredProviders, notConfiguredProviders] = useMemo(() => {
|
||||
const configuredProviders: ModelProvider[] = []
|
||||
const notConfiguredProviders: ModelProvider[] = []
|
||||
@@ -79,6 +94,26 @@ 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())
|
||||
@@ -92,28 +127,24 @@ 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',
|
||||
defaultModelNotConfigured && 'border-components-panel-border bg-components-panel-bg-blur pl-2 shadow-xs',
|
||||
showWarning && 'border-components-panel-border bg-components-panel-bg-blur pl-2 shadow-xs',
|
||||
)}
|
||||
>
|
||||
{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 && (
|
||||
{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 && (
|
||||
<div className="flex items-center gap-1 text-text-primary system-xs-medium">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<SystemModelSelector
|
||||
notConfigured={defaultModelNotConfigured}
|
||||
notConfigured={showWarning}
|
||||
textGenerationDefaultModel={textGenerationDefaultModel}
|
||||
embeddingsDefaultModel={embeddingsDefaultModel}
|
||||
rerankDefaultModel={rerankDefaultModel}
|
||||
@@ -123,11 +154,11 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} isLoading={isValidatingCurrentWorkspace} />}
|
||||
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} />}
|
||||
{!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">
|
||||
<RiBrainLine className="h-5 w-5 text-text-primary" />
|
||||
<span className="i-ri-brain-line 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>
|
||||
@@ -139,6 +170,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
<ProviderAddedCard
|
||||
key={provider.provider}
|
||||
provider={provider}
|
||||
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -152,13 +184,14 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
notConfigured
|
||||
key={provider.provider}
|
||||
provider={provider}
|
||||
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{
|
||||
enable_marketplace && (
|
||||
enableMarketplace && (
|
||||
<InstallFromMarketplace
|
||||
providers={providers}
|
||||
searchText={searchText}
|
||||
|
||||
@@ -2,12 +2,6 @@ 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" />,
|
||||
}))
|
||||
@@ -61,8 +55,12 @@ describe('CredentialItem', () => {
|
||||
|
||||
render(<CredentialItem credential={credential} onEdit={onEdit} onDelete={onDelete} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('edit-icon').closest('button') as HTMLButtonElement)
|
||||
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
|
||||
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)
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith(credential)
|
||||
expect(onDelete).toHaveBeenCalledWith(credential)
|
||||
@@ -81,7 +79,10 @@ describe('CredentialItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
|
||||
const deleteButton = screen.getAllByRole('button')
|
||||
.find(b => b.querySelector('.i-ri-delete-bin-line'))!
|
||||
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(onDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type { Credential } from '../../declarations'
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiDeleteBinLine,
|
||||
RiEqualizer2Line,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
@@ -11,7 +6,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
@@ -56,7 +51,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',
|
||||
(disabled || credential.not_allowed_to_use) ? 'cursor-not-allowed opacity-50' : onItemClick && 'cursor-pointer',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (disabled || credential.not_allowed_to_use)
|
||||
@@ -70,7 +65,7 @@ const CredentialItem = ({
|
||||
<div className="h-4 w-4">
|
||||
{
|
||||
selectedCredentialId === credential.credential_id && (
|
||||
<RiCheckLine className="h-4 w-4 text-text-accent" />
|
||||
<span className="i-ri-check-line h-4 w-4 text-text-accent" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -78,7 +73,7 @@ const CredentialItem = ({
|
||||
}
|
||||
<Indicator className="ml-2 mr-1.5 shrink-0" />
|
||||
<div
|
||||
className="system-md-regular truncate text-text-secondary"
|
||||
className="truncate text-text-secondary system-md-regular"
|
||||
title={credential.credential_name}
|
||||
>
|
||||
{credential.credential_name}
|
||||
@@ -96,38 +91,50 @@ const CredentialItem = ({
|
||||
<div className="ml-2 hidden shrink-0 items-center group-hover:flex">
|
||||
{
|
||||
!disableEdit && !credential.not_allowed_to_use && (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
{
|
||||
!disableDelete && (
|
||||
<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',
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
</ActionButton>
|
||||
/>
|
||||
<TooltipContent>
|
||||
{disableDeleteWhenSelected ? disableDeleteTip : t('operation.delete', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -139,8 +146,9 @@ const CredentialItem = ({
|
||||
|
||||
if (credential.not_allowed_to_use) {
|
||||
return (
|
||||
<Tooltip popupContent={t('auth.customCredentialUnavailable', { ns: 'plugin' })}>
|
||||
{Item}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={Item} />
|
||||
<TooltipContent>{t('auth.customCredentialUnavailable', { ns: 'plugin' })}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const ModelBadge: FC<ModelBadgeProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<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)}>
|
||||
<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)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Credential, CredentialFormSchema, ModelProvider } from '../declarations'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
@@ -243,9 +243,10 @@ describe('ModelModal', () => {
|
||||
const credential: Credential = { credential_id: 'cred-1' }
|
||||
const { onCancel } = renderModal({ credential })
|
||||
|
||||
expect(screen.getByText('common.modelProvider.confirmDelete')).toBeInTheDocument()
|
||||
const alertDialog = screen.getByRole('alertdialog', { hidden: true })
|
||||
expect(alertDialog).toHaveTextContent('common.modelProvider.confirmDelete')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
fireEvent.click(within(alertDialog).getByRole('button', { hidden: true, name: 'common.operation.confirm' }))
|
||||
|
||||
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalledTimes(1)
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -9,11 +9,9 @@ import type {
|
||||
FormRefObject,
|
||||
FormSchema,
|
||||
} from '@/app/components/base/form/types'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -21,15 +19,23 @@ 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 {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogTitle,
|
||||
} from '@/app/components/base/ui/alert-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
} from '@/app/components/base/ui/dialog'
|
||||
import {
|
||||
useAuth,
|
||||
useCredentialData,
|
||||
@@ -197,7 +203,7 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="title-2xl-semi-bold text-text-primary">
|
||||
<div className="text-text-primary title-2xl-semi-bold">
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
@@ -206,7 +212,7 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
const modalDesc = useMemo(() => {
|
||||
if (providerFormSchemaPredefined) {
|
||||
return (
|
||||
<div className="system-xs-regular mt-1 text-text-tertiary">
|
||||
<div className="mt-1 text-text-tertiary system-xs-regular">
|
||||
{t('modelProvider.auth.apiKeyModal.desc', { ns: 'common' })}
|
||||
</div>
|
||||
)
|
||||
@@ -223,7 +229,7 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
className="mr-2 h-4 w-4 shrink-0"
|
||||
provider={provider}
|
||||
/>
|
||||
<div className="system-md-regular mr-1 text-text-secondary">{renderI18nObject(provider.label)}</div>
|
||||
<div className="mr-1 text-text-secondary system-md-regular">{renderI18nObject(provider.label)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -235,7 +241,7 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
provider={provider}
|
||||
modelName={model.model}
|
||||
/>
|
||||
<div className="system-md-regular mr-1 text-text-secondary">{model.model}</div>
|
||||
<div className="mr-1 text-text-secondary system-md-regular">{model.model}</div>
|
||||
<Badge>{model.model_type}</Badge>
|
||||
</div>
|
||||
)
|
||||
@@ -275,174 +281,171 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
}, [])
|
||||
const notAllowCustomCredential = provider.allow_custom_token === false
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (!open)
|
||||
onCancel()
|
||||
}, [onCancel])
|
||||
|
||||
const handleConfirmOpenChange = useCallback((open: boolean) => {
|
||||
if (!open)
|
||||
closeConfirmDelete()
|
||||
}, [closeConfirmDelete])
|
||||
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
{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"
|
||||
>
|
||||
PKCS1_OAEP
|
||||
</a>
|
||||
{t('modelProvider.encrypted.back', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<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">
|
||||
{
|
||||
deleteCredentialId && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t('modelProvider.confirmDelete', { ns: 'common' })}
|
||||
isDisabled={doingAction}
|
||||
onCancel={closeConfirmDelete}
|
||||
onConfirm={handleDeleteCredential}
|
||||
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>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<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
|
||||
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"
|
||||
>
|
||||
PKCS1_OAEP
|
||||
</a>
|
||||
{t('modelProvider.encrypted.back', { ns: 'common' })}
|
||||
</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>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
disabled={doingAction}
|
||||
onClick={handleDeleteCredential}
|
||||
>
|
||||
{t('operation.confirm', { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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 = {
|
||||
@@ -16,24 +14,26 @@ const ModelTrigger: FC<ModelTriggerProps> = ({
|
||||
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',
|
||||
'group flex h-8 cursor-pointer items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 hover:bg-components-input-bg-hover',
|
||||
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 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>
|
||||
<div className="flex grow items-center gap-1 truncate px-1 py-[3px]">
|
||||
<div
|
||||
className="truncate text-[13px] text-text-tertiary"
|
||||
className="grow truncate text-[13px] text-text-quaternary"
|
||||
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 className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
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,51 +1,54 @@
|
||||
import type { ModelProvider } from '../declarations'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { changeModelProviderPriority } from '@/service/common'
|
||||
import { ConfigurationMethodEnum } 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 CredentialPanel from './credential-panel'
|
||||
|
||||
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,
|
||||
}
|
||||
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' }),
|
||||
}))
|
||||
|
||||
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/context', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: mockToastNotify },
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: mockEventEmitter,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
changeModelProviderPriority: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
|
||||
ConfigProvider: () => <div data-testid="config-provider" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth/hooks', () => ({
|
||||
useCredentialStatus: () => mockCredentialStatus,
|
||||
vi.mock('@/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('../hooks', () => ({
|
||||
@@ -53,93 +56,375 @@ vi.mock('../hooks', () => ({
|
||||
useUpdateModelProviders: () => mockUpdateModelProviders,
|
||||
}))
|
||||
|
||||
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('./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-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} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/indicator', () => ({
|
||||
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(mockCredentialStatus, {
|
||||
hasCredential: true,
|
||||
authorized: true,
|
||||
authRemoved: false,
|
||||
current_credential_name: 'test-credential',
|
||||
notAllowedToUse: false,
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show credential name and configuration actions', () => {
|
||||
render(<CredentialPanel provider={mockProvider} />)
|
||||
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()
|
||||
})
|
||||
|
||||
expect(screen.getByText('test-credential')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('config-provider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('priority-selector')).toBeInTheDocument()
|
||||
})
|
||||
it('should show warning icon for api-fallback variant', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
renderWithQueryClient(createProvider())
|
||||
expect(screen.getByTestId('warning-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show unauthorized status label when credential is missing', () => {
|
||||
mockCredentialStatus.hasCredential = false
|
||||
render(<CredentialPanel provider={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(/modelProvider\.auth\.unAuthorized/)).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 removed credential label and priority tip for custom preference', () => {
|
||||
mockCredentialStatus.authorized = false
|
||||
mockCredentialStatus.authRemoved = true
|
||||
render(<CredentialPanel provider={{ ...mockProvider, preferred_provider_type: 'custom' } as ModelProvider} />)
|
||||
|
||||
expect(screen.getByText(/modelProvider\.auth\.authRemoved/)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('priority-use-tip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should change priority and refresh related data after success', async () => {
|
||||
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
|
||||
mockChangePriority.mockResolvedValue({ result: 'success' })
|
||||
render(<CredentialPanel provider={mockProvider} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('priority-selector'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChangePriority).toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalled()
|
||||
expect(mockUpdateModelProviders).toHaveBeenCalled()
|
||||
expect(mockUpdateModelList).toHaveBeenCalledWith('gpt-4')
|
||||
expect(mockEventEmitter.emit).toHaveBeenCalled()
|
||||
it('should show red indicator and "Unavailable" for api-unavailable (exhausted + named unauthorized key)', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
renderWithQueryClient(createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
current_credential_id: undefined,
|
||||
current_credential_name: 'Bad Key',
|
||||
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
|
||||
},
|
||||
}))
|
||||
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'red')
|
||||
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Bad Key')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render standalone priority selector without provider schema', () => {
|
||||
const providerNoSchema = {
|
||||
...mockProvider,
|
||||
provider_credential_schema: null,
|
||||
} as unknown as ModelProvider
|
||||
render(<CredentialPanel provider={providerNoSchema} />)
|
||||
expect(screen.getByTestId('priority-selector')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('config-provider')).not.toBeInTheDocument()
|
||||
describe('Destructive styling', () => {
|
||||
it('should apply destructive container for credits-exhausted', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
const { container } = renderWithQueryClient(createProvider({
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.noConfigure,
|
||||
available_credentials: [],
|
||||
},
|
||||
}))
|
||||
expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should apply destructive container for no-usage variant', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
const { container } = renderWithQueryClient(createProvider({
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
current_credential_id: undefined,
|
||||
current_credential_name: undefined,
|
||||
available_credentials: [{ credential_id: 'cred-1' }],
|
||||
},
|
||||
}))
|
||||
expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should apply destructive container for api-unavailable variant', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
const { container } = renderWithQueryClient(createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
current_credential_id: undefined,
|
||||
current_credential_name: 'Bad Key',
|
||||
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
|
||||
},
|
||||
}))
|
||||
expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should apply default container for credits-active', () => {
|
||||
const { container } = renderWithQueryClient(createProvider())
|
||||
expect(container.querySelector('[class*="bg-white"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should apply default container for api-active', () => {
|
||||
const { container } = renderWithQueryClient(createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
}))
|
||||
expect(container.querySelector('[class*="bg-white"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should apply default container for api-fallback', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
const { container } = renderWithQueryClient(createProvider())
|
||||
expect(container.querySelector('[class*="bg-white"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Text color', () => {
|
||||
it('should use destructive text color for credits-exhausted label', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
const { container } = renderWithQueryClient(createProvider({
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.noConfigure,
|
||||
available_credentials: [],
|
||||
},
|
||||
}))
|
||||
expect(container.querySelector('.text-text-destructive')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should use secondary text color for credits-active label', () => {
|
||||
const { container } = renderWithQueryClient(createProvider())
|
||||
expect(container.querySelector('.text-text-secondary')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Priority change', () => {
|
||||
it('should call mutation with correct params on priority change', async () => {
|
||||
renderWithQueryClient(createProvider())
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('change-priority-btn'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChangePriorityFn.mock.calls[0]?.[0]).toEqual({
|
||||
params: { provider: 'test-provider' },
|
||||
body: { preferred_provider_type: 'custom' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show success toast and refresh data after successful mutation', async () => {
|
||||
renderWithQueryClient(createProvider())
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('change-priority-btn'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'success' }),
|
||||
)
|
||||
expect(mockUpdateModelProviders).toHaveBeenCalled()
|
||||
expect(mockUpdateModelList).toHaveBeenCalledWith('llm')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ModelAuthDropdown integration', () => {
|
||||
it('should pass credits-active variant to dropdown when credits available', () => {
|
||||
renderWithQueryClient(createProvider())
|
||||
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-active')
|
||||
})
|
||||
|
||||
it('should pass api-fallback variant to dropdown when exhausted with valid key', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
renderWithQueryClient(createProvider())
|
||||
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-fallback')
|
||||
})
|
||||
|
||||
it('should pass credits-exhausted variant when exhausted with no credentials', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
renderWithQueryClient(createProvider({
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.noConfigure,
|
||||
available_credentials: [],
|
||||
},
|
||||
}))
|
||||
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-exhausted')
|
||||
})
|
||||
|
||||
it('should pass api-active variant for custom priority with authorized key', () => {
|
||||
renderWithQueryClient(createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
}))
|
||||
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-active')
|
||||
})
|
||||
|
||||
it('should pass credits-fallback variant for custom priority with no credentials and credits available', () => {
|
||||
renderWithQueryClient(createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.noConfigure,
|
||||
available_credentials: [],
|
||||
},
|
||||
}))
|
||||
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-fallback')
|
||||
})
|
||||
|
||||
it('should pass credits-fallback variant for custom priority with named unauthorized key and credits available', () => {
|
||||
renderWithQueryClient(createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
current_credential_id: undefined,
|
||||
current_credential_name: 'Bad Key',
|
||||
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
|
||||
},
|
||||
}))
|
||||
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-fallback')
|
||||
})
|
||||
|
||||
it('should pass no-usage variant when exhausted + credential but unauthorized', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
renderWithQueryClient(createProvider({
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
current_credential_id: undefined,
|
||||
current_credential_name: undefined,
|
||||
available_credentials: [{ credential_id: 'cred-1' }],
|
||||
},
|
||||
}))
|
||||
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'no-usage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('apiKeyOnly priority (system disabled)', () => {
|
||||
it('should derive api-required-add when system config disabled and no credentials', () => {
|
||||
renderWithQueryClient(createProvider({
|
||||
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
|
||||
preferred_provider_type: PreferredProviderTypeEnum.system,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.noConfigure,
|
||||
available_credentials: [],
|
||||
},
|
||||
}))
|
||||
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-required-add')
|
||||
expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should derive api-active when system config disabled but has authorized key', () => {
|
||||
renderWithQueryClient(createProvider({
|
||||
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
|
||||
}))
|
||||
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-active')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,151 +1,161 @@
|
||||
import type {
|
||||
ModelProvider,
|
||||
PreferredProviderTypeEnum,
|
||||
} from '../declarations'
|
||||
import { useMemo } from 'react'
|
||||
import type { CardVariant } from './use-credential-panel-state'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 Warning from '@/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { changeModelProviderPriority } from '@/service/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
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'
|
||||
import ModelAuthDropdown from './model-auth-dropdown'
|
||||
import SystemQuotaCard from './system-quota-card'
|
||||
import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state'
|
||||
|
||||
type CredentialPanelProps = {
|
||||
provider: ModelProvider
|
||||
}
|
||||
|
||||
const TEXT_LABEL_VARIANTS = new Set<CardVariant>([
|
||||
'credits-active',
|
||||
'credits-fallback',
|
||||
'credits-exhausted',
|
||||
'no-usage',
|
||||
'api-required-add',
|
||||
'api-required-configure',
|
||||
])
|
||||
|
||||
const CredentialPanel = ({
|
||||
provider,
|
||||
}: CredentialPanelProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const queryClient = useQueryClient()
|
||||
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,
|
||||
const state = useCredentialPanelState(provider)
|
||||
const providerName = provider.provider
|
||||
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
provider: providerName,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate: changePriority, isPending: isChangingPriority } = useMutation(
|
||||
consoleQuery.modelProviders.changePreferredProviderType.mutationOptions({
|
||||
onSuccess: () => {
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: modelProviderModelListQueryKey,
|
||||
exact: true,
|
||||
refetchType: 'none',
|
||||
})
|
||||
updateModelProviders()
|
||||
provider.configurate_methods.forEach((method) => {
|
||||
if (method === ConfigurationMethodEnum.predefinedModel)
|
||||
provider.supported_model_types.forEach(modelType => updateModelList(modelType))
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const handleChangePriority = (key: PreferredProviderTypeEnum) => {
|
||||
changePriority({
|
||||
params: { provider: providerName },
|
||||
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 { variant, credentialName } = state
|
||||
const isDestructive = isDestructiveVariant(variant)
|
||||
const isTextLabel = TEXT_LABEL_VARIANTS.has(variant)
|
||||
const needsGap = !isTextLabel || variant === 'credits-fallback'
|
||||
|
||||
const color = useMemo(() => {
|
||||
if (authRemoved || !hasCredential)
|
||||
return 'red'
|
||||
if (notAllowedToUse)
|
||||
return 'gray'
|
||||
return 'green'
|
||||
}, [authRemoved, notAllowedToUse, hasCredential])
|
||||
return (
|
||||
<SystemQuotaCard variant={isDestructive ? 'destructive' : 'default'}>
|
||||
<SystemQuotaCard.Label className={needsGap ? 'gap-1' : undefined}>
|
||||
{isTextLabel
|
||||
? <TextLabel variant={variant} />
|
||||
: <StatusLabel variant={variant} credentialName={credentialName} />}
|
||||
</SystemQuotaCard.Label>
|
||||
<SystemQuotaCard.Actions>
|
||||
<ModelAuthDropdown
|
||||
provider={provider}
|
||||
state={state}
|
||||
isChangingPriority={isChangingPriority}
|
||||
onChangePriority={handleChangePriority}
|
||||
/>
|
||||
</SystemQuotaCard.Actions>
|
||||
</SystemQuotaCard>
|
||||
)
|
||||
}
|
||||
|
||||
const TEXT_LABEL_KEYS = {
|
||||
'credits-active': 'modelProvider.card.aiCreditsInUse',
|
||||
'credits-fallback': 'modelProvider.card.aiCreditsInUse',
|
||||
'credits-exhausted': 'modelProvider.card.quotaExhausted',
|
||||
'no-usage': 'modelProvider.card.noAvailableUsage',
|
||||
'api-required-add': 'modelProvider.card.apiKeyRequired',
|
||||
'api-required-configure': 'modelProvider.card.apiKeyRequired',
|
||||
} as const satisfies Partial<Record<CardVariant, string>>
|
||||
|
||||
function TextLabel({ variant }: { variant: CardVariant }) {
|
||||
const { t } = useTranslation()
|
||||
const isDestructive = isDestructiveVariant(variant)
|
||||
const labelKey = TEXT_LABEL_KEYS[variant as keyof typeof TEXT_LABEL_KEYS]
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
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>
|
||||
)
|
||||
}
|
||||
<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" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CredentialPanel
|
||||
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)
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import type { ModelItem, ModelProvider } from '../declarations'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ModelProvider } from '../declarations'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fetchModelProviderModelList } from '@/service/common'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { useExpandModelProviderList } from '../atoms'
|
||||
import { ConfigurationMethodEnum } from '../declarations'
|
||||
import ProviderAddedCard from './index'
|
||||
|
||||
let mockIsCurrentWorkspaceManager = true
|
||||
const mockEventEmitter = {
|
||||
useSubscription: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
}
|
||||
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,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
fetchModelProviderModelList: vi.fn(),
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
modelProviders: {
|
||||
models: {
|
||||
queryOptions: (options: { input: { params: { provider: string } }, enabled?: boolean }) => mockQueryOptions(options),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
@@ -20,12 +31,6 @@ 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" />,
|
||||
@@ -53,6 +58,38 @@ 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',
|
||||
@@ -67,19 +104,21 @@ describe('ProviderAddedCard', () => {
|
||||
})
|
||||
|
||||
it('should render provider added card component', () => {
|
||||
render(<ProviderAddedCard provider={mockProvider} />)
|
||||
renderWithQueryClient(<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 () => {
|
||||
vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: [{ model: 'gpt-4' }] } as unknown as { data: ModelItem[] })
|
||||
render(<ProviderAddedCard provider={mockProvider} />)
|
||||
mockFetchModelProviderModels.mockResolvedValue({ data: [{ model: 'gpt-4' }] })
|
||||
renderWithQueryClient(<ProviderAddedCard provider={mockProvider} />)
|
||||
|
||||
const showModelsBtn = screen.getByTestId('show-models-button')
|
||||
fireEvent.click(showModelsBtn)
|
||||
|
||||
expect(fetchModelProviderModelList).toHaveBeenCalledWith(`/workspaces/current/model-providers/${mockProvider.provider}/models`)
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModelProviderModels).toHaveBeenCalledWith(mockProvider.provider)
|
||||
})
|
||||
expect(await screen.findByTestId('model-list')).toBeInTheDocument()
|
||||
|
||||
// Test line 71-72: Opening when already fetched
|
||||
@@ -90,13 +129,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(fetchModelProviderModelList).toHaveBeenCalledTimes(1) // Should not fetch again
|
||||
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(2) // Re-open fetches again with default stale/gc behavior
|
||||
|
||||
// Refresh list from ModelList
|
||||
const refreshBtn = screen.getByRole('button', { name: 'refresh list' })
|
||||
fireEvent.click(refreshBtn)
|
||||
await waitFor(() => {
|
||||
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,18 +144,20 @@ describe('ProviderAddedCard', () => {
|
||||
const promise = new Promise((resolve) => {
|
||||
resolveOuter = resolve
|
||||
})
|
||||
vi.mocked(fetchModelProviderModelList).mockReturnValue(promise as unknown as ReturnType<typeof fetchModelProviderModelList>)
|
||||
mockFetchModelProviderModels.mockReturnValue(promise)
|
||||
|
||||
render(<ProviderAddedCard provider={mockProvider} />)
|
||||
renderWithQueryClient(<ProviderAddedCard provider={mockProvider} />)
|
||||
const showModelsBtn = screen.getByTestId('show-models-button')
|
||||
|
||||
// First call sets loading to true
|
||||
fireEvent.click(showModelsBtn)
|
||||
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Second call should return early because loading is true
|
||||
fireEvent.click(showModelsBtn)
|
||||
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveOuter({ data: [] })
|
||||
@@ -125,56 +166,49 @@ describe('ProviderAddedCard', () => {
|
||||
expect(await screen.findByTestId('model-list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should only react to external expansion for the matching provider', async () => {
|
||||
mockFetchModelProviderModels.mockResolvedValue({ data: [{ model: 'gpt-4' }] })
|
||||
renderWithQueryClient(
|
||||
<>
|
||||
<ProviderAddedCard provider={mockProvider} />
|
||||
<ExternalExpandControls />
|
||||
</>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('expand-other-provider'))
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('expand-current-provider'))
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModelProviderModels).toHaveBeenCalledWith(mockProvider.provider)
|
||||
})
|
||||
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render configure tip when provider is not in quota list and not configured', () => {
|
||||
const providerWithoutQuota = {
|
||||
...mockProvider,
|
||||
provider: 'custom/provider',
|
||||
} as unknown as ModelProvider
|
||||
render(<ProviderAddedCard provider={providerWithoutQuota} notConfigured />)
|
||||
renderWithQueryClient(<ProviderAddedCard provider={providerWithoutQuota} notConfigured />)
|
||||
expect(screen.getByText('common.modelProvider.configureTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should refresh model list on event subscription', async () => {
|
||||
let capturedHandler: (v: { type: string, payload: string } | null) => void = () => { }
|
||||
mockEventEmitter.useSubscription.mockImplementation((handler: (v: unknown) => void) => {
|
||||
capturedHandler = handler as (v: { type: string, payload: string } | null) => void
|
||||
})
|
||||
vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: [] } as unknown as { data: ModelItem[] })
|
||||
|
||||
render(<ProviderAddedCard provider={mockProvider} />)
|
||||
|
||||
expect(capturedHandler).toBeDefined()
|
||||
act(() => {
|
||||
capturedHandler({
|
||||
type: 'UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST',
|
||||
payload: mockProvider.provider,
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Should ignore non-matching events
|
||||
act(() => {
|
||||
capturedHandler({ type: 'OTHER', payload: '' })
|
||||
capturedHandler(null)
|
||||
})
|
||||
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render custom model actions for workspace managers', () => {
|
||||
const customConfigProvider = {
|
||||
...mockProvider,
|
||||
configurate_methods: [ConfigurationMethodEnum.customizableModel],
|
||||
} as unknown as ModelProvider
|
||||
const { rerender } = render(<ProviderAddedCard provider={customConfigProvider} />)
|
||||
const { unmount } = renderWithQueryClient(<ProviderAddedCard provider={customConfigProvider} />)
|
||||
|
||||
expect(screen.getByTestId('manage-custom-model')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('add-custom-model')).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
rerender(<ProviderAddedCard provider={customConfigProvider} />)
|
||||
renderWithQueryClient(<ProviderAddedCard provider={customConfigProvider} />)
|
||||
expect(screen.queryByTestId('manage-custom-model')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AddCustomModel,
|
||||
@@ -13,9 +14,10 @@ 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 { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { fetchModelProviderModelList } from '@/service/common'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useModelProviderListExpanded, useSetModelProviderListExpanded } from '../atoms'
|
||||
import { ConfigurationMethodEnum } from '../declarations'
|
||||
import ModelBadge from '../model-badge'
|
||||
import ProviderIcon from '../provider-icon'
|
||||
@@ -25,121 +27,123 @@ 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 { 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 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 systemConfig = provider.system_configuration
|
||||
const hasModelList = fetched && !!modelList.length
|
||||
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 { isCurrentWorkspaceManager } = useAppContext()
|
||||
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 showModelProvider = systemConfig.enabled && MODEL_PROVIDER_QUOTA_GET_PAID.includes(currentProviderName as ModelProviderQuotaGetPaid) && !IS_CE_EDITION
|
||||
const showCredential = supportsPredefinedModel && isCurrentWorkspaceManager
|
||||
const showCustomModelActions = supportsCustomizableModel && isCurrentWorkspaceManager
|
||||
|
||||
const getModelList = async (providerName: string) => {
|
||||
const refreshModelList = useCallback((targetProviderName: string) => {
|
||||
if (targetProviderName !== currentProviderName)
|
||||
return
|
||||
|
||||
if (!expanded)
|
||||
setExpanded(true)
|
||||
|
||||
refetchModelList().catch(() => {})
|
||||
}, [currentProviderName, expanded, refetchModelList, setExpanded])
|
||||
|
||||
const handleOpenModelList = useCallback(() => {
|
||||
if (loading)
|
||||
return
|
||||
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)
|
||||
|
||||
if (!expanded) {
|
||||
setExpanded(true)
|
||||
return
|
||||
}
|
||||
|
||||
getModelList(provider.provider)
|
||||
}
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v?.type === UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST && v.payload === provider.provider)
|
||||
getModelList(v.payload)
|
||||
})
|
||||
refetchModelList().catch(() => {})
|
||||
}, [expanded, loading, refetchModelList, setExpanded])
|
||||
|
||||
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',
|
||||
provider.provider === 'langgenius/openai/openai' && 'bg-third-party-model-bg-openai',
|
||||
provider.provider === 'langgenius/anthropic/anthropic' && 'bg-third-party-model-bg-anthropic',
|
||||
currentProviderName === 'langgenius/openai/openai' && 'bg-third-party-model-bg-openai',
|
||||
currentProviderName === '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">
|
||||
<ProviderIcon
|
||||
className="mb-2"
|
||||
provider={provider}
|
||||
/>
|
||||
<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>
|
||||
))
|
||||
}
|
||||
{provider.supported_model_types.map(modelType => (
|
||||
<ModelBadge key={modelType}>
|
||||
{modelTypeFormat(modelType)}
|
||||
</ModelBadge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
showCredential && (
|
||||
<CredentialPanel
|
||||
provider={provider}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{showCredential && (
|
||||
<CredentialPanel
|
||||
provider={provider}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
collapsed && (
|
||||
showCollapsedSection && (
|
||||
<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) && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
<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>
|
||||
)}
|
||||
{!showModelProvider && notConfigured && (
|
||||
<div className="flex h-6 items-center pl-1 pr-1.5">
|
||||
@@ -148,7 +152,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
configurationMethods.includes(ConfigurationMethodEnum.customizableModel) && isCurrentWorkspaceManager && (
|
||||
showCustomModelActions && (
|
||||
<div className="flex grow justify-end">
|
||||
<ManageCustomModelCredentials
|
||||
provider={provider}
|
||||
@@ -166,12 +170,12 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
!collapsed && (
|
||||
!showCollapsedSection && (
|
||||
<ModelList
|
||||
provider={provider}
|
||||
models={modelList}
|
||||
onCollapse={() => setCollapsed(true)}
|
||||
onChange={(provider: string) => getModelList(provider)}
|
||||
onCollapse={() => setExpanded(false)}
|
||||
onChange={refreshModelList}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -179,4 +183,4 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default ProviderAddedCard
|
||||
export default memo(ProviderAddedCard)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
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)
|
||||
@@ -0,0 +1,63 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
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>
|
||||
)
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
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)
|
||||
@@ -0,0 +1,211 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
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)
|
||||
@@ -0,0 +1,66 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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,9 +1,17 @@
|
||||
import type { ModelItem, ModelProvider } from '../declarations'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { disableModel, enableModel } from '@/service/common'
|
||||
import { ModelStatusEnum } from '../declarations'
|
||||
import ModelListItem from './model-list-item'
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
let mockModelLoadBalancingEnabled = false
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
@@ -69,6 +77,7 @@ describe('ModelListItem', () => {
|
||||
provider={mockProvider}
|
||||
isConfigurable={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-name')).toBeInTheDocument()
|
||||
@@ -83,6 +92,7 @@ describe('ModelListItem', () => {
|
||||
isConfigurable={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
@@ -102,6 +112,7 @@ describe('ModelListItem', () => {
|
||||
isConfigurable={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
@@ -122,6 +133,7 @@ describe('ModelListItem', () => {
|
||||
isConfigurable={false}
|
||||
onModifyLoadBalancing={onModifyLoadBalancing}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'modify load balancing' }))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -9,6 +10,7 @@ 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'
|
||||
@@ -30,16 +32,30 @@ 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, onChange, provider.provider, updateModelList])
|
||||
}, [model.model, model.model_type, modelProviderModelListQueryKey, onChange, provider.provider, queryClient, updateModelList])
|
||||
|
||||
const { run: debouncedToggleModelEnablingStatus } = useDebounceFn(toggleModelEnablingStatus, { wait: 500 })
|
||||
|
||||
@@ -58,7 +74,7 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad
|
||||
modelName={model.model}
|
||||
/>
|
||||
<ModelName
|
||||
className="system-md-regular grow text-text-secondary"
|
||||
className="grow text-text-secondary system-md-regular"
|
||||
modelItem={model}
|
||||
showModelType
|
||||
showMode
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { FC } from 'react'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { HeaderModals } from '@/app/components/plugins/plugin-detail-panel/detail-header/components'
|
||||
import { useDetailHeaderState, usePluginOperations } from '@/app/components/plugins/plugin-detail-panel/detail-header/hooks'
|
||||
import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown'
|
||||
import { PluginSource } from '@/app/components/plugins/types'
|
||||
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
type Props = {
|
||||
detail: PluginDetail
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const locale = useLocale()
|
||||
|
||||
const { source, version, latest_version, latest_unique_identifier, meta } = detail
|
||||
const author = detail.declaration?.author ?? ''
|
||||
const name = detail.declaration?.name ?? detail.name
|
||||
|
||||
const {
|
||||
modalStates,
|
||||
versionPicker,
|
||||
hasNewVersion,
|
||||
isAutoUpgradeEnabled,
|
||||
isFromMarketplace,
|
||||
isFromGitHub,
|
||||
} = useDetailHeaderState(detail)
|
||||
|
||||
const {
|
||||
handleUpdate,
|
||||
handleUpdatedFromMarketplace,
|
||||
handleDelete,
|
||||
} = usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace,
|
||||
onUpdate,
|
||||
})
|
||||
|
||||
const handleVersionSelect = (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => {
|
||||
versionPicker.setTargetVersion(state)
|
||||
handleUpdate(state.isDowngrade)
|
||||
}
|
||||
|
||||
const handleTriggerLatestUpdate = () => {
|
||||
if (isFromMarketplace) {
|
||||
versionPicker.setTargetVersion({
|
||||
version: latest_version,
|
||||
unique_identifier: latest_unique_identifier,
|
||||
})
|
||||
}
|
||||
handleUpdate()
|
||||
}
|
||||
|
||||
const detailUrl = useMemo(() => {
|
||||
if (source === PluginSource.github)
|
||||
return meta?.repo ? `https://github.com/${meta.repo}` : ''
|
||||
if (source === PluginSource.marketplace)
|
||||
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: locale, theme })
|
||||
return ''
|
||||
}, [source, meta?.repo, author, name, locale, theme])
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!version && (
|
||||
<PluginVersionPicker
|
||||
disabled={!isFromMarketplace}
|
||||
isShow={versionPicker.isShow}
|
||||
onShowChange={versionPicker.setIsShow}
|
||||
pluginID={detail.plugin_id}
|
||||
currentVersion={version}
|
||||
onSelect={handleVersionSelect}
|
||||
sideOffset={4}
|
||||
alignOffset={0}
|
||||
trigger={(
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex min-w-5 items-center justify-center gap-[3px] rounded-md border border-divider-deep bg-state-base-hover px-[5px] py-[2px] text-text-tertiary system-xs-medium-uppercase',
|
||||
isFromMarketplace && 'cursor-pointer hover:bg-state-base-hover-alt',
|
||||
)}
|
||||
>
|
||||
<span>{version}</span>
|
||||
{isFromMarketplace && <span aria-hidden className="i-ri-arrow-left-right-line h-3 w-3" />}
|
||||
{hasNewVersion && (
|
||||
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 rounded-full bg-state-destructive-solid" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(hasNewVersion || isFromGitHub) && (
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
className="!h-5"
|
||||
onClick={handleTriggerLatestUpdate}
|
||||
>
|
||||
{t('detailPanel.operation.update', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<OperationDropdown
|
||||
source={source}
|
||||
onInfo={modalStates.showPluginInfo}
|
||||
onCheckVersion={() => handleUpdate()}
|
||||
onRemove={modalStates.showDeleteConfirm}
|
||||
detailUrl={detailUrl}
|
||||
placement="bottom-start"
|
||||
popupClassName="w-[192px]"
|
||||
/>
|
||||
|
||||
<HeaderModals
|
||||
detail={detail}
|
||||
modalStates={modalStates}
|
||||
targetVersion={versionPicker.targetVersion}
|
||||
isDowngrade={versionPicker.isDowngrade}
|
||||
isAutoUpgradeEnabled={isAutoUpgradeEnabled}
|
||||
onUpdatedFromMarketplace={handleUpdatedFromMarketplace}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProviderCardActions
|
||||
@@ -0,0 +1,8 @@
|
||||
.gridBg {
|
||||
background-size: 4px 4px;
|
||||
background-image:
|
||||
linear-gradient(to right, var(--color-divider-subtle) 0.5px, transparent 0.5px),
|
||||
linear-gradient(to bottom, var(--color-divider-subtle) 0.5px, transparent 0.5px);
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6), transparent 70%);
|
||||
mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6), transparent 70%);
|
||||
}
|
||||
@@ -2,11 +2,16 @@ import type { ModelProvider } from '../declarations'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import QuotaPanel from './quota-panel'
|
||||
|
||||
let mockWorkspace = {
|
||||
let mockWorkspaceData: {
|
||||
trial_credits: number
|
||||
trial_credits_used: number
|
||||
next_credit_reset_date: string
|
||||
} | undefined = {
|
||||
trial_credits: 100,
|
||||
trial_credits_used: 30,
|
||||
next_credit_reset_date: '2024-12-31',
|
||||
}
|
||||
let mockWorkspaceIsPending = false
|
||||
let mockTrialModels: string[] = ['langgenius/openai/openai']
|
||||
let mockPlugins = [{
|
||||
plugin_id: 'langgenius/openai',
|
||||
@@ -25,15 +30,16 @@ vi.mock('@/app/components/base/icons/src/public/llm', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: mockWorkspace,
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useCurrentWorkspace: () => ({
|
||||
data: mockWorkspaceData,
|
||||
isPending: mockWorkspaceIsPending,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { trial_models: string[] } }) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
useSystemFeaturesQuery: () => ({
|
||||
data: {
|
||||
trial_models: mockTrialModels,
|
||||
},
|
||||
}),
|
||||
@@ -71,22 +77,21 @@ describe('QuotaPanel', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspace = {
|
||||
mockWorkspaceData = {
|
||||
trial_credits: 100,
|
||||
trial_credits_used: 30,
|
||||
next_credit_reset_date: '2024-12-31',
|
||||
}
|
||||
mockWorkspaceIsPending = false
|
||||
mockTrialModels = ['langgenius/openai/openai']
|
||||
mockPlugins = [{ plugin_id: 'langgenius/openai', latest_package_identifier: 'openai@1.0.0' }]
|
||||
})
|
||||
|
||||
it('should render loading state', () => {
|
||||
render(
|
||||
<QuotaPanel
|
||||
providers={mockProviders}
|
||||
isLoading
|
||||
/>,
|
||||
)
|
||||
mockWorkspaceData = undefined
|
||||
mockWorkspaceIsPending = true
|
||||
|
||||
render(<QuotaPanel providers={mockProviders} />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -102,8 +107,17 @@ describe('QuotaPanel', () => {
|
||||
expect(screen.getByText(/modelProvider\.resetDate/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep quota content during background refetch when cached workspace exists', () => {
|
||||
mockWorkspaceIsPending = true
|
||||
|
||||
render(<QuotaPanel providers={mockProviders} />)
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('70')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should floor credits at zero when usage is higher than quota', () => {
|
||||
mockWorkspace = {
|
||||
mockWorkspaceData = {
|
||||
trial_credits: 10,
|
||||
trial_credits_used: 999,
|
||||
next_credit_reset_date: '',
|
||||
@@ -111,7 +125,7 @@ describe('QuotaPanel', () => {
|
||||
|
||||
render(<QuotaPanel providers={mockProviders} />)
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(screen.getByText(/modelProvider\.card\.quotaExhausted/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/modelProvider\.resetDate/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -7,10 +7,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AnthropicShortLight, Deepseek, Gemini, Grok, OpenaiSmall, Tongyi } from '@/app/components/base/icons/src/public/llm'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useSystemFeaturesQuery } from '@/context/global-public-context'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { ModelProviderQuotaGetPaid } from '@/types/model-provider'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -18,8 +17,9 @@ import { formatNumber } from '@/utils/format'
|
||||
import { PreferredProviderTypeEnum } from '../declarations'
|
||||
import { useMarketplaceAllPlugins } from '../hooks'
|
||||
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap } from '../utils'
|
||||
import styles from './quota-panel.module.css'
|
||||
import { useTrialCredits } from './use-trial-credits'
|
||||
|
||||
// Icon map for each provider - single source of truth for provider icons
|
||||
const providerIconMap: Record<ModelProviderQuotaGetPaid, ComponentType<{ className?: string }>> = {
|
||||
[ModelProviderQuotaGetPaid.OPENAI]: OpenaiSmall,
|
||||
[ModelProviderQuotaGetPaid.ANTHROPIC]: AnthropicShortLight,
|
||||
@@ -29,14 +29,11 @@ const providerIconMap: Record<ModelProviderQuotaGetPaid, ComponentType<{ classNa
|
||||
[ModelProviderQuotaGetPaid.TONGYI]: Tongyi,
|
||||
}
|
||||
|
||||
// Derive allProviders from the shared constant
|
||||
const allProviders = MODEL_PROVIDER_QUOTA_GET_PAID.map(key => ({
|
||||
key,
|
||||
Icon: providerIconMap[key],
|
||||
}))
|
||||
|
||||
// Map provider key to plugin ID
|
||||
// provider key format: langgenius/provider/model, plugin ID format: langgenius/provider
|
||||
const providerKeyToPluginId: Record<ModelProviderQuotaGetPaid, string> = {
|
||||
[ModelProviderQuotaGetPaid.OPENAI]: 'langgenius/openai',
|
||||
[ModelProviderQuotaGetPaid.ANTHROPIC]: 'langgenius/anthropic',
|
||||
@@ -48,16 +45,14 @@ const providerKeyToPluginId: Record<ModelProviderQuotaGetPaid, string> = {
|
||||
|
||||
type QuotaPanelProps = {
|
||||
providers: ModelProvider[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
const QuotaPanel: FC<QuotaPanelProps> = ({
|
||||
providers,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { currentWorkspace } = useAppContext()
|
||||
const { trial_models } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const credits = Math.max((currentWorkspace.trial_credits - currentWorkspace.trial_credits_used) || 0, 0)
|
||||
const { credits, isExhausted, isLoading, nextCreditResetDate } = useTrialCredits()
|
||||
const { data: systemFeatures } = useSystemFeaturesQuery()
|
||||
const trialModels = systemFeatures?.trial_models ?? []
|
||||
const providerMap = useMemo(() => new Map(
|
||||
providers.map(p => [p.provider, p.preferred_provider_type]),
|
||||
), [providers])
|
||||
@@ -98,6 +93,11 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
|
||||
}
|
||||
}, [providers, isShowInstallModal, hideInstallFromMarketplace])
|
||||
|
||||
const tipText = t('modelProvider.card.tip', {
|
||||
ns: 'common',
|
||||
modelNames: trialModels.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', '),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="my-2 flex min-h-[72px] items-center justify-center rounded-xl border-[0.5px] border-components-panel-border bg-third-party-model-bg-default shadow-xs">
|
||||
@@ -107,59 +107,88 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('my-2 min-w-[72px] shrink-0 rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs', credits <= 0 ? 'border-state-destructive-border hover:bg-state-destructive-hover' : 'border-components-panel-border bg-third-party-model-bg-default')}>
|
||||
<div className="system-xs-medium-uppercase mb-2 flex h-4 items-center text-text-tertiary">
|
||||
{t('modelProvider.quota', { ns: 'common' })}
|
||||
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common', modelNames: trial_models.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', ') })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-xs text-text-tertiary">
|
||||
<span className="system-md-semibold-uppercase mr-0.5 text-text-secondary">{formatNumber(credits)}</span>
|
||||
<span>{t('modelProvider.credits', { ns: 'common' })}</span>
|
||||
{currentWorkspace.next_credit_reset_date
|
||||
? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{t('modelProvider.resetDate', {
|
||||
ns: 'common',
|
||||
date: formatTime(currentWorkspace.next_credit_reset_date, t('dateFormat', { ns: 'appLog' })),
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
<div className={cn(
|
||||
'relative my-2 min-w-[72px] shrink-0 overflow-hidden rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs',
|
||||
isExhausted
|
||||
? 'border-state-destructive-border hover:bg-state-destructive-hover'
|
||||
: 'border-components-panel-border bg-third-party-model-bg-default',
|
||||
)}
|
||||
>
|
||||
<div className={cn('pointer-events-none absolute inset-0', styles.gridBg)} />
|
||||
<div className="relative">
|
||||
<div className="mb-2 flex h-4 items-center text-text-tertiary system-xs-medium-uppercase">
|
||||
{t('modelProvider.quota', { ns: 'common' })}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
aria-label={tipText}
|
||||
delay={0}
|
||||
render={(
|
||||
<span className="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{tipText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{allProviders.filter(({ key }) => trial_models.includes(key)).map(({ key, Icon }) => {
|
||||
const providerType = providerMap.get(key)
|
||||
const isConfigured = (installedProvidersMap.get(key)?.length ?? 0) > 0 // means the provider is configured API key
|
||||
const getTooltipKey = () => {
|
||||
// if provider type is not set, it means the provider is not installed
|
||||
if (!providerType)
|
||||
return 'modelProvider.card.modelNotSupported'
|
||||
if (isConfigured && providerType === PreferredProviderTypeEnum.custom)
|
||||
return 'modelProvider.card.modelAPI'
|
||||
return 'modelProvider.card.modelSupported'
|
||||
}
|
||||
return (
|
||||
<Tooltip
|
||||
key={key}
|
||||
popupContent={t(getTooltipKey(), { modelName: modelNameMap[key], ns: 'common' })}
|
||||
>
|
||||
<div
|
||||
className={cn('relative h-6 w-6', !providerType && 'cursor-pointer hover:opacity-80')}
|
||||
onClick={() => handleIconClick(key)}
|
||||
>
|
||||
<Icon className="h-6 w-6 rounded-lg" />
|
||||
{!providerType && (
|
||||
<div className="absolute inset-0 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge opacity-30" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-xs text-text-tertiary">
|
||||
{credits > 0
|
||||
? <span className="mr-0.5 text-text-secondary system-xl-semibold">{formatNumber(credits)}</span>
|
||||
: <span className="mr-0.5 text-text-destructive system-xl-semibold">{t('modelProvider.card.quotaExhausted', { ns: 'common' })}</span>}
|
||||
{nextCreditResetDate
|
||||
? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{t('modelProvider.resetDate', {
|
||||
ns: 'common',
|
||||
date: formatTime(nextCreditResetDate, t('dateFormat', { ns: 'appLog' })),
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{allProviders.filter(({ key }) => trialModels.includes(key)).map(({ key, Icon }) => {
|
||||
const providerType = providerMap.get(key)
|
||||
const isConfigured = (installedProvidersMap.get(key)?.length ?? 0) > 0
|
||||
const getTooltipKey = () => {
|
||||
if (!providerType)
|
||||
return 'modelProvider.card.modelNotSupported'
|
||||
if (isConfigured && providerType === PreferredProviderTypeEnum.custom)
|
||||
return 'modelProvider.card.modelAPI'
|
||||
return 'modelProvider.card.modelSupported'
|
||||
}
|
||||
const tooltipText = t(getTooltipKey(), { modelName: modelNameMap[key], ns: 'common' })
|
||||
return (
|
||||
<Tooltip key={key}>
|
||||
<TooltipTrigger
|
||||
aria-label={tooltipText}
|
||||
delay={0}
|
||||
render={(
|
||||
<div
|
||||
className={cn('relative h-6 w-6', !providerType && 'cursor-pointer hover:opacity-80')}
|
||||
onClick={() => handleIconClick(key)}
|
||||
>
|
||||
<Icon className="h-6 w-6 rounded-lg" />
|
||||
{!providerType && (
|
||||
<div className="absolute inset-0 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge opacity-30" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{tooltipText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isShowInstallModal && selectedPlugin && (
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import SystemQuotaCard from './system-quota-card'
|
||||
|
||||
describe('SystemQuotaCard', () => {
|
||||
// Renders container with children
|
||||
describe('Rendering', () => {
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<SystemQuotaCard>
|
||||
<span>content</span>
|
||||
</SystemQuotaCard>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply default variant styles', () => {
|
||||
const { container } = render(
|
||||
<SystemQuotaCard>
|
||||
<span>test</span>
|
||||
</SystemQuotaCard>,
|
||||
)
|
||||
|
||||
const card = container.firstElementChild!
|
||||
expect(card.className).toContain('bg-white')
|
||||
})
|
||||
|
||||
it('should apply destructive variant styles', () => {
|
||||
const { container } = render(
|
||||
<SystemQuotaCard variant="destructive">
|
||||
<span>test</span>
|
||||
</SystemQuotaCard>,
|
||||
)
|
||||
|
||||
const card = container.firstElementChild!
|
||||
expect(card.className).toContain('border-state-destructive-border')
|
||||
})
|
||||
})
|
||||
|
||||
// Label sub-component
|
||||
describe('Label', () => {
|
||||
it('should apply default variant text color when no className provided', () => {
|
||||
render(
|
||||
<SystemQuotaCard>
|
||||
<SystemQuotaCard.Label>Default label</SystemQuotaCard.Label>
|
||||
</SystemQuotaCard>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Default label').className).toContain('text-text-secondary')
|
||||
})
|
||||
|
||||
it('should apply destructive variant text color when no className provided', () => {
|
||||
render(
|
||||
<SystemQuotaCard variant="destructive">
|
||||
<SystemQuotaCard.Label>Error label</SystemQuotaCard.Label>
|
||||
</SystemQuotaCard>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Error label').className).toContain('text-text-destructive')
|
||||
})
|
||||
|
||||
it('should override variant color with custom className', () => {
|
||||
render(
|
||||
<SystemQuotaCard variant="destructive">
|
||||
<SystemQuotaCard.Label className="gap-1">Custom label</SystemQuotaCard.Label>
|
||||
</SystemQuotaCard>,
|
||||
)
|
||||
|
||||
const label = screen.getByText('Custom label')
|
||||
expect(label.className).toContain('gap-1')
|
||||
expect(label.className).not.toContain('text-text-destructive')
|
||||
})
|
||||
})
|
||||
|
||||
// Actions sub-component
|
||||
describe('Actions', () => {
|
||||
it('should render action children', () => {
|
||||
render(
|
||||
<SystemQuotaCard>
|
||||
<SystemQuotaCard.Actions>
|
||||
<button>Click me</button>
|
||||
</SystemQuotaCard.Actions>
|
||||
</SystemQuotaCard>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { createContext, useContext } from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import styles from './quota-panel.module.css'
|
||||
|
||||
type Variant = 'default' | 'destructive'
|
||||
|
||||
const VariantContext = createContext<Variant>('default')
|
||||
|
||||
const containerVariants: Record<Variant, string> = {
|
||||
default: 'border-components-panel-border bg-white/[0.18]',
|
||||
destructive: 'border-state-destructive-border bg-state-destructive-hover',
|
||||
}
|
||||
|
||||
const labelVariants: Record<Variant, string> = {
|
||||
default: 'text-text-secondary',
|
||||
destructive: 'text-text-destructive',
|
||||
}
|
||||
|
||||
type SystemQuotaCardProps = {
|
||||
variant?: Variant
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const SystemQuotaCard = ({
|
||||
variant = 'default',
|
||||
children,
|
||||
}: SystemQuotaCardProps) => {
|
||||
return (
|
||||
<VariantContext.Provider value={variant}>
|
||||
<div className={cn(
|
||||
'relative isolate ml-1 flex w-[128px] shrink-0 flex-col justify-between rounded-lg border-[0.5px] p-1 shadow-xs',
|
||||
containerVariants[variant],
|
||||
)}
|
||||
>
|
||||
<div className={cn('pointer-events-none absolute inset-0 rounded-[7px]', styles.gridBg)} />
|
||||
{children}
|
||||
</div>
|
||||
</VariantContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const Label = ({ children, className }: { children: ReactNode, className?: string }) => {
|
||||
const variant = useContext(VariantContext)
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative z-[1] flex items-center gap-1 truncate px-1.5 pt-1 system-xs-medium',
|
||||
className ?? labelVariants[variant],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Actions = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<div className="relative z-[1] flex items-center gap-0.5">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
SystemQuotaCard.Label = Label
|
||||
SystemQuotaCard.Actions = Actions
|
||||
|
||||
export default SystemQuotaCard
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { ModelProvider } from '../declarations'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
PreferredProviderTypeEnum,
|
||||
} from '../declarations'
|
||||
import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state'
|
||||
|
||||
const mockTrialCredits = { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false, nextCreditResetDate: undefined }
|
||||
|
||||
vi.mock('./use-trial-credits', () => ({
|
||||
useTrialCredits: () => mockTrialCredits,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return { ...actual, IS_CLOUD_EDITION: true }
|
||||
})
|
||||
|
||||
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
|
||||
provider: 'test-provider',
|
||||
provider_credential_schema: { credential_form_schemas: [] },
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
current_credential_id: 'cred-1',
|
||||
current_credential_name: 'My Key',
|
||||
available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }],
|
||||
},
|
||||
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
|
||||
preferred_provider_type: PreferredProviderTypeEnum.system,
|
||||
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||
supported_model_types: ['llm'],
|
||||
...overrides,
|
||||
} as unknown as ModelProvider)
|
||||
|
||||
describe('useCredentialPanelState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.assign(mockTrialCredits, { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false })
|
||||
})
|
||||
|
||||
// Credits priority variants
|
||||
describe('Credits priority variants', () => {
|
||||
it('should return credits-active when credits available', () => {
|
||||
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
|
||||
|
||||
expect(result.current.variant).toBe('credits-active')
|
||||
expect(result.current.priority).toBe('credits')
|
||||
expect(result.current.supportsCredits).toBe(true)
|
||||
})
|
||||
|
||||
it('should return api-fallback when credits exhausted but API key authorized', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
mockTrialCredits.credits = 0
|
||||
|
||||
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
|
||||
|
||||
expect(result.current.variant).toBe('api-fallback')
|
||||
})
|
||||
|
||||
it('should return no-usage when credits exhausted and API key unauthorized', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
const provider = createProvider({
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
current_credential_id: undefined,
|
||||
current_credential_name: undefined,
|
||||
available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCredentialPanelState(provider))
|
||||
|
||||
expect(result.current.variant).toBe('no-usage')
|
||||
})
|
||||
|
||||
it('should return credits-exhausted when credits exhausted and no credentials', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
const provider = createProvider({
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.noConfigure,
|
||||
available_credentials: [],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCredentialPanelState(provider))
|
||||
|
||||
expect(result.current.variant).toBe('credits-exhausted')
|
||||
})
|
||||
})
|
||||
|
||||
// API key priority variants
|
||||
describe('API key priority variants', () => {
|
||||
it('should return api-active when API key authorized', () => {
|
||||
const provider = createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCredentialPanelState(provider))
|
||||
|
||||
expect(result.current.variant).toBe('api-active')
|
||||
expect(result.current.priority).toBe('apiKey')
|
||||
})
|
||||
|
||||
it('should return credits-fallback when API key unauthorized and credits available', () => {
|
||||
const provider = createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
current_credential_id: undefined,
|
||||
current_credential_name: undefined,
|
||||
available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCredentialPanelState(provider))
|
||||
|
||||
expect(result.current.variant).toBe('credits-fallback')
|
||||
})
|
||||
|
||||
it('should return credits-fallback when no credentials and credits available', () => {
|
||||
const provider = createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.noConfigure,
|
||||
available_credentials: [],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCredentialPanelState(provider))
|
||||
|
||||
expect(result.current.variant).toBe('credits-fallback')
|
||||
})
|
||||
|
||||
it('should return no-usage when no credentials and credits exhausted', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
mockTrialCredits.credits = 0
|
||||
const provider = createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.noConfigure,
|
||||
available_credentials: [],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCredentialPanelState(provider))
|
||||
|
||||
expect(result.current.variant).toBe('no-usage')
|
||||
})
|
||||
|
||||
it('should return api-unavailable when credential with name unauthorized and credits exhausted', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
mockTrialCredits.credits = 0
|
||||
const provider = createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
current_credential_id: undefined,
|
||||
current_credential_name: 'Bad Key',
|
||||
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCredentialPanelState(provider))
|
||||
|
||||
expect(result.current.variant).toBe('api-unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
// apiKeyOnly priority
|
||||
describe('apiKeyOnly priority (non-cloud / system disabled)', () => {
|
||||
it('should return apiKeyOnly when system config disabled', () => {
|
||||
const provider = createProvider({
|
||||
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCredentialPanelState(provider))
|
||||
|
||||
expect(result.current.priority).toBe('apiKeyOnly')
|
||||
expect(result.current.supportsCredits).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Derived metadata
|
||||
describe('Derived metadata', () => {
|
||||
it('should show priority switcher when credits supported and custom config active', () => {
|
||||
const provider = createProvider()
|
||||
|
||||
const { result } = renderHook(() => useCredentialPanelState(provider))
|
||||
|
||||
expect(result.current.showPrioritySwitcher).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide priority switcher when system config disabled', () => {
|
||||
const provider = createProvider({
|
||||
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCredentialPanelState(provider))
|
||||
|
||||
expect(result.current.showPrioritySwitcher).toBe(false)
|
||||
})
|
||||
|
||||
it('should expose credential name from provider', () => {
|
||||
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
|
||||
|
||||
expect(result.current.credentialName).toBe('My Key')
|
||||
})
|
||||
|
||||
it('should expose credits amount', () => {
|
||||
mockTrialCredits.credits = 500
|
||||
|
||||
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
|
||||
|
||||
expect(result.current.credits).toBe(500)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDestructiveVariant', () => {
|
||||
it.each([
|
||||
['credits-exhausted', true],
|
||||
['no-usage', true],
|
||||
['api-unavailable', true],
|
||||
['credits-active', false],
|
||||
['api-fallback', false],
|
||||
['api-active', false],
|
||||
['api-required-add', false],
|
||||
['api-required-configure', false],
|
||||
] as const)('should return %s for variant %s', (variant, expected) => {
|
||||
expect(isDestructiveVariant(variant)).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ModelProvider } from '../declarations'
|
||||
import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import {
|
||||
PreferredProviderTypeEnum,
|
||||
} from '../declarations'
|
||||
import { useTrialCredits } from './use-trial-credits'
|
||||
|
||||
export type UsagePriority = 'credits' | 'apiKey' | 'apiKeyOnly'
|
||||
|
||||
export type CardVariant
|
||||
= | 'credits-active'
|
||||
| 'credits-fallback'
|
||||
| 'credits-exhausted'
|
||||
| 'no-usage'
|
||||
| 'api-fallback'
|
||||
| 'api-active'
|
||||
| 'api-required-add'
|
||||
| 'api-required-configure'
|
||||
| 'api-unavailable'
|
||||
|
||||
export type CredentialPanelState = {
|
||||
variant: CardVariant
|
||||
priority: UsagePriority
|
||||
supportsCredits: boolean
|
||||
showPrioritySwitcher: boolean
|
||||
hasCredentials: boolean
|
||||
isCreditsExhausted: boolean
|
||||
credentialName: string | undefined
|
||||
credits: number
|
||||
}
|
||||
|
||||
const DESTRUCTIVE_VARIANTS = new Set<CardVariant>([
|
||||
'credits-exhausted',
|
||||
'no-usage',
|
||||
'api-unavailable',
|
||||
])
|
||||
|
||||
export const isDestructiveVariant = (variant: CardVariant) =>
|
||||
DESTRUCTIVE_VARIANTS.has(variant)
|
||||
|
||||
function deriveVariant(
|
||||
priority: UsagePriority,
|
||||
isExhausted: boolean,
|
||||
hasCredential: boolean,
|
||||
authorized: boolean | undefined,
|
||||
credentialName: string | undefined,
|
||||
): CardVariant {
|
||||
if (priority === 'credits') {
|
||||
if (!isExhausted)
|
||||
return 'credits-active'
|
||||
if (hasCredential && authorized)
|
||||
return 'api-fallback'
|
||||
if (hasCredential && !authorized)
|
||||
return 'no-usage'
|
||||
return 'credits-exhausted'
|
||||
}
|
||||
|
||||
if (hasCredential && authorized)
|
||||
return 'api-active'
|
||||
|
||||
if (priority === 'apiKey' && !isExhausted)
|
||||
return 'credits-fallback'
|
||||
|
||||
if (priority === 'apiKey' && !hasCredential)
|
||||
return 'no-usage'
|
||||
|
||||
if (hasCredential && !authorized)
|
||||
return credentialName ? 'api-unavailable' : 'api-required-configure'
|
||||
return 'api-required-add'
|
||||
}
|
||||
|
||||
export function useCredentialPanelState(provider: ModelProvider): CredentialPanelState {
|
||||
const { isExhausted, credits } = useTrialCredits()
|
||||
const {
|
||||
hasCredential,
|
||||
authorized,
|
||||
current_credential_name,
|
||||
} = useCredentialStatus(provider)
|
||||
|
||||
const systemConfig = provider.system_configuration
|
||||
const preferredType = provider.preferred_provider_type
|
||||
|
||||
const supportsCredits = systemConfig.enabled && IS_CLOUD_EDITION
|
||||
|
||||
const priority: UsagePriority = !supportsCredits
|
||||
? 'apiKeyOnly'
|
||||
: preferredType === PreferredProviderTypeEnum.system
|
||||
? 'credits'
|
||||
: 'apiKey'
|
||||
|
||||
const showPrioritySwitcher = supportsCredits
|
||||
|
||||
const variant = deriveVariant(priority, isExhausted, hasCredential, !!authorized, current_credential_name)
|
||||
|
||||
return {
|
||||
variant,
|
||||
priority,
|
||||
supportsCredits,
|
||||
showPrioritySwitcher,
|
||||
hasCredentials: hasCredential,
|
||||
isCreditsExhausted: isExhausted,
|
||||
credentialName: current_credential_name,
|
||||
credits,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useCurrentWorkspace } from '@/service/use-common'
|
||||
|
||||
export const useTrialCredits = () => {
|
||||
const { data: currentWorkspace, isPending } = useCurrentWorkspace()
|
||||
const totalCredits = currentWorkspace?.trial_credits ?? 0
|
||||
const credits = Math.max(totalCredits - (currentWorkspace?.trial_credits_used ?? 0), 0)
|
||||
|
||||
return {
|
||||
credits,
|
||||
totalCredits,
|
||||
isExhausted: credits <= 0,
|
||||
isLoading: isPending && !currentWorkspace,
|
||||
nextCreditResetDate: currentWorkspace?.next_credit_reset_date,
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const ProviderIcon: FC<ProviderIconProps> = ({
|
||||
|
||||
if (provider.provider === 'langgenius/anthropic/anthropic') {
|
||||
return (
|
||||
<div className="mb-2 py-[7px]">
|
||||
<div className={cn('py-[7px]', className)}>
|
||||
{theme === Theme.dark && <AnthropicLight className="h-2.5 w-[90px]" />}
|
||||
{theme === Theme.light && <AnthropicDark className="h-2.5 w-[90px]" />}
|
||||
</div>
|
||||
@@ -30,7 +30,7 @@ const ProviderIcon: FC<ProviderIconProps> = ({
|
||||
|
||||
if (provider.provider === 'langgenius/openai/openai') {
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className={className}>
|
||||
<Openai className="h-6 w-auto text-text-inverted-dimmed" />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock('react-i18next', async () => {
|
||||
|
||||
const mockNotify = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateModelList = vi.hoisted(() => vi.fn())
|
||||
const mockInvalidateDefaultModel = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateDefaultModel = vi.hoisted(() => vi.fn(() => Promise.resolve({ result: 'success' })))
|
||||
|
||||
let mockIsCurrentWorkspaceManager = true
|
||||
@@ -57,6 +58,7 @@ vi.mock('../hooks', () => ({
|
||||
vi.fn(),
|
||||
],
|
||||
useUpdateModelList: () => mockUpdateModelList,
|
||||
useInvalidateDefaultModel: () => mockInvalidateDefaultModel,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
@@ -99,7 +101,7 @@ describe('SystemModel', () => {
|
||||
expect(screen.getByRole('button', { name: /system model settings/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open modal when button is clicked', async () => {
|
||||
it('should open dialog when button is clicked', async () => {
|
||||
render(<SystemModel {...defaultProps} />)
|
||||
const button = screen.getByRole('button', { name: /system model settings/i })
|
||||
fireEvent.click(button)
|
||||
@@ -113,7 +115,7 @@ describe('SystemModel', () => {
|
||||
expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should close modal when cancel is clicked', async () => {
|
||||
it('should close dialog when cancel is clicked', async () => {
|
||||
render(<SystemModel {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
|
||||
await waitFor(() => {
|
||||
@@ -144,6 +146,7 @@ describe('SystemModel', () => {
|
||||
type: 'success',
|
||||
message: 'Modified successfully',
|
||||
})
|
||||
expect(mockInvalidateDefaultModel).toHaveBeenCalledTimes(5)
|
||||
expect(mockUpdateModelList).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,22 +3,27 @@ import type {
|
||||
DefaultModel,
|
||||
DefaultModelResponse,
|
||||
} from '../declarations'
|
||||
import { RiEqualizer2Line, RiLoader2Line } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/app/components/base/ui/dialog'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/app/components/base/ui/tooltip'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { updateDefaultModel } from '@/service/common'
|
||||
import { ModelTypeEnum } from '../declarations'
|
||||
import {
|
||||
useInvalidateDefaultModel,
|
||||
useModelList,
|
||||
useSystemDefaultModelAndModelList,
|
||||
useUpdateModelList,
|
||||
@@ -34,6 +39,21 @@ type SystemModelSelectorProps = {
|
||||
notConfigured: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
type SystemModelLabelKey
|
||||
= | 'modelProvider.systemReasoningModel.key'
|
||||
| 'modelProvider.embeddingModel.key'
|
||||
| 'modelProvider.rerankModel.key'
|
||||
| 'modelProvider.speechToTextModel.key'
|
||||
| 'modelProvider.ttsModel.key'
|
||||
|
||||
type SystemModelTipKey
|
||||
= | 'modelProvider.systemReasoningModel.tip'
|
||||
| 'modelProvider.embeddingModel.tip'
|
||||
| 'modelProvider.rerankModel.tip'
|
||||
| 'modelProvider.speechToTextModel.tip'
|
||||
| 'modelProvider.ttsModel.tip'
|
||||
|
||||
const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
textGenerationDefaultModel,
|
||||
embeddingsDefaultModel,
|
||||
@@ -48,6 +68,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const { textGenerationModelList } = useProviderContext()
|
||||
const updateModelList = useUpdateModelList()
|
||||
const invalidateDefaultModel = useInvalidateDefaultModel()
|
||||
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
|
||||
const { data: speech2textModelList } = useModelList(ModelTypeEnum.speech2text)
|
||||
@@ -106,154 +127,124 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
|
||||
setOpen(false)
|
||||
|
||||
changedModelTypes.forEach((modelType) => {
|
||||
if (modelType === ModelTypeEnum.textGeneration)
|
||||
updateModelList(modelType)
|
||||
else if (modelType === ModelTypeEnum.textEmbedding)
|
||||
updateModelList(modelType)
|
||||
else if (modelType === ModelTypeEnum.rerank)
|
||||
updateModelList(modelType)
|
||||
else if (modelType === ModelTypeEnum.speech2text)
|
||||
updateModelList(modelType)
|
||||
else if (modelType === ModelTypeEnum.tts)
|
||||
updateModelList(modelType)
|
||||
})
|
||||
const allModelTypes = [ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank, ModelTypeEnum.speech2text, ModelTypeEnum.tts]
|
||||
allModelTypes.forEach(type => invalidateDefaultModel(type))
|
||||
changedModelTypes.forEach(type => updateModelList(type))
|
||||
}
|
||||
}
|
||||
|
||||
const renderModelLabel = (labelKey: SystemModelLabelKey, tipKey: SystemModelTipKey) => {
|
||||
const tipText = t(tipKey, { ns: 'common' })
|
||||
|
||||
return (
|
||||
<div className="flex min-h-6 items-center text-[13px] font-medium text-text-secondary">
|
||||
{t(labelKey, { ns: 'common' })}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
aria-label={tipText}
|
||||
delay={0}
|
||||
render={(
|
||||
<span className="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<div className="w-[261px] text-text-tertiary">
|
||||
{tipText}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 8,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild onClick={() => setOpen(v => !v)}>
|
||||
<Button
|
||||
className="relative"
|
||||
variant={notConfigured ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
disabled={isLoading}
|
||||
<>
|
||||
<Button
|
||||
className="relative"
|
||||
variant={notConfigured ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
disabled={isLoading}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{isLoading
|
||||
? <span className="i-ri-loader-2-line mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
: <span className="i-ri-equalizer-2-line mr-1 h-3.5 w-3.5" />}
|
||||
{t('modelProvider.systemModelSettings', { ns: 'common' })}
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-[480px] max-w-[480px] overflow-hidden p-0"
|
||||
>
|
||||
{isLoading
|
||||
? <RiLoader2Line className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
: <RiEqualizer2Line className="mr-1 h-3.5 w-3.5" />}
|
||||
{t('modelProvider.systemModelSettings', { ns: 'common' })}
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[60]">
|
||||
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg pt-4 shadow-xl">
|
||||
<div className="px-6 py-1">
|
||||
<div className="flex h-8 items-center text-[13px] font-medium text-text-primary">
|
||||
{t('modelProvider.systemReasoningModel.key', { ns: 'common' })}
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[261px] text-text-tertiary">
|
||||
{t('modelProvider.systemReasoningModel.tip', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
triggerClassName="ml-0.5 w-4 h-4 shrink-0"
|
||||
/>
|
||||
<DialogCloseButton className="right-5 top-5" />
|
||||
<div className="px-6 pb-3 pr-14 pt-6">
|
||||
<DialogTitle className="text-text-primary title-2xl-semi-bold">
|
||||
{t('modelProvider.systemModelSettings', { ns: 'common' })}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-6 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel('modelProvider.systemReasoningModel.key', 'modelProvider.systemReasoningModel.tip')}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentTextGenerationDefaultModel}
|
||||
modelList={textGenerationModelList}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.textGeneration, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentTextGenerationDefaultModel}
|
||||
modelList={textGenerationModelList}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.textGeneration, model)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel('modelProvider.embeddingModel.key', 'modelProvider.embeddingModel.tip')}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentEmbeddingsDefaultModel}
|
||||
modelList={embeddingModelList}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel('modelProvider.rerankModel.key', 'modelProvider.rerankModel.tip')}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentRerankDefaultModel}
|
||||
modelList={rerankModelList}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.rerank, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel('modelProvider.speechToTextModel.key', 'modelProvider.speechToTextModel.tip')}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentSpeech2textDefaultModel}
|
||||
modelList={speech2textModelList}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.speech2text, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel('modelProvider.ttsModel.key', 'modelProvider.ttsModel.tip')}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentTTSDefaultModel}
|
||||
modelList={ttsModelList}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.tts, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-1">
|
||||
<div className="flex h-8 items-center text-[13px] font-medium text-text-primary">
|
||||
{t('modelProvider.embeddingModel.key', { ns: 'common' })}
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[261px] text-text-tertiary">
|
||||
{t('modelProvider.embeddingModel.tip', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
triggerClassName="ml-0.5 w-4 h-4 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentEmbeddingsDefaultModel}
|
||||
modelList={embeddingModelList}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-1">
|
||||
<div className="flex h-8 items-center text-[13px] font-medium text-text-primary">
|
||||
{t('modelProvider.rerankModel.key', { ns: 'common' })}
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[261px] text-text-tertiary">
|
||||
{t('modelProvider.rerankModel.tip', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
triggerClassName="ml-0.5 w-4 h-4 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentRerankDefaultModel}
|
||||
modelList={rerankModelList}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.rerank, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-1">
|
||||
<div className="flex h-8 items-center text-[13px] font-medium text-text-primary">
|
||||
{t('modelProvider.speechToTextModel.key', { ns: 'common' })}
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[261px] text-text-tertiary">
|
||||
{t('modelProvider.speechToTextModel.tip', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
triggerClassName="ml-0.5 w-4 h-4 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentSpeech2textDefaultModel}
|
||||
modelList={speech2textModelList}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.speech2text, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-1">
|
||||
<div className="flex h-8 items-center text-[13px] font-medium text-text-primary">
|
||||
{t('modelProvider.ttsModel.key', { ns: 'common' })}
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[261px] text-text-tertiary">
|
||||
{t('modelProvider.ttsModel.tip', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
triggerClassName="ml-0.5 w-4 h-4 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentTTSDefaultModel}
|
||||
modelList={ttsModelList}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.tts, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2 px-6 pb-6 pt-5">
|
||||
<Button
|
||||
className="min-w-[72px]"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-2"
|
||||
className="min-w-[72px]"
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!isCurrentWorkspaceManager}
|
||||
@@ -261,9 +252,9 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
|
||||
export { ModelProviderQuotaGetPaid } from '@/types/model-provider'
|
||||
|
||||
export const providerToPluginId = (providerKey: string): string => {
|
||||
const lastSlash = providerKey.lastIndexOf('/')
|
||||
return lastSlash > 0 ? providerKey.slice(0, lastSlash) : ''
|
||||
}
|
||||
|
||||
export const MODEL_PROVIDER_QUOTA_GET_PAID = [ModelProviderQuotaGetPaid.OPENAI, ModelProviderQuotaGetPaid.ANTHROPIC, ModelProviderQuotaGetPaid.GEMINI, ModelProviderQuotaGetPaid.X, ModelProviderQuotaGetPaid.DEEPSEEK, ModelProviderQuotaGetPaid.TONGYI]
|
||||
|
||||
export const modelNameMap = {
|
||||
|
||||
@@ -3,6 +3,16 @@ import { act, renderHook } from '@testing-library/react'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import {
|
||||
useActivePluginType,
|
||||
useFilterPluginTags,
|
||||
useMarketplaceMoreClick,
|
||||
useMarketplaceSearchMode,
|
||||
useMarketplaceSort,
|
||||
useMarketplaceSortValue,
|
||||
useSearchPluginText,
|
||||
useSetMarketplaceSort,
|
||||
} from '../atoms'
|
||||
import { DEFAULT_SORT } from '../constants'
|
||||
|
||||
const createWrapper = (searchParams = '') => {
|
||||
@@ -22,8 +32,7 @@ describe('Marketplace sort atoms', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return default sort value from useMarketplaceSort', async () => {
|
||||
const { useMarketplaceSort } = await import('../atoms')
|
||||
it('should return default sort value from useMarketplaceSort', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useMarketplaceSort(), { wrapper })
|
||||
|
||||
@@ -31,24 +40,28 @@ describe('Marketplace sort atoms', () => {
|
||||
expect(typeof result.current[1]).toBe('function')
|
||||
})
|
||||
|
||||
it('should return default sort value from useMarketplaceSortValue', async () => {
|
||||
const { useMarketplaceSortValue } = await import('../atoms')
|
||||
it('should return default sort value from useMarketplaceSortValue', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useMarketplaceSortValue(), { wrapper })
|
||||
|
||||
expect(result.current).toEqual(DEFAULT_SORT)
|
||||
})
|
||||
|
||||
it('should return setter from useSetMarketplaceSort', async () => {
|
||||
const { useSetMarketplaceSort } = await import('../atoms')
|
||||
it('should return setter from useSetMarketplaceSort', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useSetMarketplaceSort(), { wrapper })
|
||||
const { result } = renderHook(() => ({
|
||||
setSort: useSetMarketplaceSort(),
|
||||
sortValue: useMarketplaceSortValue(),
|
||||
}), { wrapper })
|
||||
|
||||
expect(typeof result.current).toBe('function')
|
||||
act(() => {
|
||||
result.current.setSort({ sortBy: 'created_at', sortOrder: 'ASC' })
|
||||
})
|
||||
|
||||
expect(result.current.sortValue).toEqual({ sortBy: 'created_at', sortOrder: 'ASC' })
|
||||
})
|
||||
|
||||
it('should update sort value via useMarketplaceSort setter', async () => {
|
||||
const { useMarketplaceSort } = await import('../atoms')
|
||||
it('should update sort value via useMarketplaceSort setter', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useMarketplaceSort(), { wrapper })
|
||||
|
||||
@@ -65,8 +78,7 @@ describe('useSearchPluginText', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return empty string as default', async () => {
|
||||
const { useSearchPluginText } = await import('../atoms')
|
||||
it('should return empty string as default', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
|
||||
|
||||
@@ -74,8 +86,7 @@ describe('useSearchPluginText', () => {
|
||||
expect(typeof result.current[1]).toBe('function')
|
||||
})
|
||||
|
||||
it('should parse q from search params', async () => {
|
||||
const { useSearchPluginText } = await import('../atoms')
|
||||
it('should parse q from search params', () => {
|
||||
const { wrapper } = createWrapper('?q=hello')
|
||||
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
|
||||
|
||||
@@ -83,16 +94,14 @@ describe('useSearchPluginText', () => {
|
||||
})
|
||||
|
||||
it('should expose a setter function for search text', async () => {
|
||||
const { useSearchPluginText } = await import('../atoms')
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
|
||||
|
||||
expect(typeof result.current[1]).toBe('function')
|
||||
|
||||
// Calling the setter should not throw
|
||||
await act(async () => {
|
||||
result.current[1]('search term')
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe('search term')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -101,16 +110,14 @@ describe('useActivePluginType', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return "all" as default category', async () => {
|
||||
const { useActivePluginType } = await import('../atoms')
|
||||
it('should return "all" as default category', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useActivePluginType(), { wrapper })
|
||||
|
||||
expect(result.current[0]).toBe('all')
|
||||
})
|
||||
|
||||
it('should parse category from search params', async () => {
|
||||
const { useActivePluginType } = await import('../atoms')
|
||||
it('should parse category from search params', () => {
|
||||
const { wrapper } = createWrapper('?category=tool')
|
||||
const { result } = renderHook(() => useActivePluginType(), { wrapper })
|
||||
|
||||
@@ -123,16 +130,14 @@ describe('useFilterPluginTags', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return empty array as default', async () => {
|
||||
const { useFilterPluginTags } = await import('../atoms')
|
||||
it('should return empty array as default', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useFilterPluginTags(), { wrapper })
|
||||
|
||||
expect(result.current[0]).toEqual([])
|
||||
})
|
||||
|
||||
it('should parse tags from search params', async () => {
|
||||
const { useFilterPluginTags } = await import('../atoms')
|
||||
it('should parse tags from search params', () => {
|
||||
const { wrapper } = createWrapper('?tags=search')
|
||||
const { result } = renderHook(() => useFilterPluginTags(), { wrapper })
|
||||
|
||||
@@ -145,42 +150,35 @@ describe('useMarketplaceSearchMode', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return false when no search text, no tags, and category has collections (all)', async () => {
|
||||
const { useMarketplaceSearchMode } = await import('../atoms')
|
||||
it('should return false when no search text, no tags, and category has collections (all)', () => {
|
||||
const { wrapper } = createWrapper('?category=all')
|
||||
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
|
||||
|
||||
// "all" is in PLUGIN_CATEGORY_WITH_COLLECTIONS, so search mode should be false
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when search text is present', async () => {
|
||||
const { useMarketplaceSearchMode } = await import('../atoms')
|
||||
it('should return true when search text is present', () => {
|
||||
const { wrapper } = createWrapper('?q=test&category=all')
|
||||
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when tags are present', async () => {
|
||||
const { useMarketplaceSearchMode } = await import('../atoms')
|
||||
it('should return true when tags are present', () => {
|
||||
const { wrapper } = createWrapper('?tags=search&category=all')
|
||||
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when category does not have collections (e.g. model)', async () => {
|
||||
const { useMarketplaceSearchMode } = await import('../atoms')
|
||||
it('should return true when category does not have collections (e.g. model)', () => {
|
||||
const { wrapper } = createWrapper('?category=model')
|
||||
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
|
||||
|
||||
// "model" is NOT in PLUGIN_CATEGORY_WITH_COLLECTIONS, so search mode = true
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when category has collections (tool) and no search/tags', async () => {
|
||||
const { useMarketplaceSearchMode } = await import('../atoms')
|
||||
it('should return false when category has collections (tool) and no search/tags', () => {
|
||||
const { wrapper } = createWrapper('?category=tool')
|
||||
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
|
||||
|
||||
@@ -193,27 +191,33 @@ describe('useMarketplaceMoreClick', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return a callback function', async () => {
|
||||
const { useMarketplaceMoreClick } = await import('../atoms')
|
||||
it('should return a callback function', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper })
|
||||
|
||||
expect(typeof result.current).toBe('function')
|
||||
})
|
||||
|
||||
it('should do nothing when called with no params', async () => {
|
||||
const { useMarketplaceMoreClick } = await import('../atoms')
|
||||
it('should do nothing when called with no params', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper })
|
||||
const { result } = renderHook(() => ({
|
||||
handleMoreClick: useMarketplaceMoreClick(),
|
||||
sort: useMarketplaceSortValue(),
|
||||
searchText: useSearchPluginText()[0],
|
||||
}), { wrapper })
|
||||
|
||||
const sortBefore = result.current.sort
|
||||
const searchTextBefore = result.current.searchText
|
||||
|
||||
// Should not throw when called with undefined
|
||||
act(() => {
|
||||
result.current(undefined)
|
||||
result.current.handleMoreClick(undefined)
|
||||
})
|
||||
|
||||
expect(result.current.sort).toEqual(sortBefore)
|
||||
expect(result.current.searchText).toBe(searchTextBefore)
|
||||
})
|
||||
|
||||
it('should update search state when called with search params', async () => {
|
||||
const { useMarketplaceMoreClick, useMarketplaceSortValue } = await import('../atoms')
|
||||
it('should update search state when called with search params', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
|
||||
const { result } = renderHook(() => ({
|
||||
@@ -229,17 +233,20 @@ describe('useMarketplaceMoreClick', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Sort should be updated via the jotai atom
|
||||
expect(result.current.sort).toEqual({ sortBy: 'created_at', sortOrder: 'ASC' })
|
||||
})
|
||||
|
||||
it('should use defaults when search params fields are missing', async () => {
|
||||
const { useMarketplaceMoreClick } = await import('../atoms')
|
||||
it('should use defaults when search params fields are missing', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper })
|
||||
const { result } = renderHook(() => ({
|
||||
handleMoreClick: useMarketplaceMoreClick(),
|
||||
sort: useMarketplaceSortValue(),
|
||||
}), { wrapper })
|
||||
|
||||
act(() => {
|
||||
result.current({})
|
||||
result.current.handleMoreClick({})
|
||||
})
|
||||
|
||||
expect(result.current.sort).toEqual(DEFAULT_SORT)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,31 +74,40 @@ describe('PluginTypeSwitch', () => {
|
||||
const { Wrapper } = createWrapper('?category=all')
|
||||
render(<PluginTypeSwitch />, { wrapper: Wrapper })
|
||||
|
||||
// Click on Models option — should not throw
|
||||
expect(() => fireEvent.click(screen.getByText('Models'))).not.toThrow()
|
||||
fireEvent.click(screen.getByText('Models'))
|
||||
|
||||
const modelsButton = screen.getByText('Models').closest('div')
|
||||
expect(modelsButton?.className).toContain('!bg-components-main-nav-nav-button-bg-active')
|
||||
})
|
||||
|
||||
it('should handle clicking on category with collections (Tools)', () => {
|
||||
const { Wrapper } = createWrapper('?category=model')
|
||||
render(<PluginTypeSwitch />, { wrapper: Wrapper })
|
||||
|
||||
// Click on "Tools" which has collections → setSearchMode(null)
|
||||
expect(() => fireEvent.click(screen.getByText('Tools'))).not.toThrow()
|
||||
fireEvent.click(screen.getByText('Tools'))
|
||||
|
||||
const toolsButton = screen.getByText('Tools').closest('div')
|
||||
expect(toolsButton?.className).toContain('!bg-components-main-nav-nav-button-bg-active')
|
||||
})
|
||||
|
||||
it('should handle clicking on category without collections (Models)', () => {
|
||||
const { Wrapper } = createWrapper('?category=all')
|
||||
render(<PluginTypeSwitch />, { wrapper: Wrapper })
|
||||
|
||||
// Click on "Models" which does NOT have collections → no setSearchMode call
|
||||
expect(() => fireEvent.click(screen.getByText('Models'))).not.toThrow()
|
||||
fireEvent.click(screen.getByText('Models'))
|
||||
|
||||
const modelsButton = screen.getByText('Models').closest('div')
|
||||
expect(modelsButton?.className).toContain('!bg-components-main-nav-nav-button-bg-active')
|
||||
})
|
||||
|
||||
it('should handle clicking on bundles', () => {
|
||||
const { Wrapper } = createWrapper('?category=all')
|
||||
render(<PluginTypeSwitch />, { wrapper: Wrapper })
|
||||
|
||||
expect(() => fireEvent.click(screen.getByText('Bundles'))).not.toThrow()
|
||||
fireEvent.click(screen.getByText('Bundles'))
|
||||
|
||||
const bundlesButton = screen.getByText('Bundles').closest('div')
|
||||
expect(bundlesButton?.className).toContain('!bg-components-main-nav-nav-button-bg-active')
|
||||
})
|
||||
|
||||
it('should handle clicking on each category', () => {
|
||||
@@ -107,7 +116,10 @@ describe('PluginTypeSwitch', () => {
|
||||
|
||||
const categories = ['All', 'Models', 'Tools', 'Data Sources', 'Triggers', 'Agents', 'Extensions', 'Bundles']
|
||||
categories.forEach((category) => {
|
||||
expect(() => fireEvent.click(screen.getByText(category))).not.toThrow()
|
||||
fireEvent.click(screen.getByText(category))
|
||||
|
||||
const button = screen.getByText(category).closest('div')
|
||||
expect(button?.className).toContain('!bg-components-main-nav-nav-button-bg-active')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -79,6 +79,10 @@ vi.mock('@/service/plugins', () => ({
|
||||
uninstallPlugin: mockUninstallPlugin,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInvalidateCheckInstalled: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllToolProviders: () => ({ data: [] }),
|
||||
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
|
||||
@@ -218,23 +222,6 @@ vi.mock('../../plugin-auth', () => ({
|
||||
PluginAuth: () => <div data-testid="plugin-auth" />,
|
||||
}))
|
||||
|
||||
// Mock Confirm component
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ isShow, onCancel, onConfirm, isLoading }: {
|
||||
isShow: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
isLoading: boolean
|
||||
}) => isShow
|
||||
? (
|
||||
<div data-testid="delete-confirm">
|
||||
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="confirm-ok" onClick={onConfirm} disabled={isLoading}>Confirm</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'test-id',
|
||||
created_at: '2024-01-01',
|
||||
@@ -801,7 +788,7 @@ describe('DetailHeader', () => {
|
||||
fireEvent.click(screen.getByTestId('remove-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -810,13 +797,13 @@ describe('DetailHeader', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-btn'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-cancel'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('delete-confirm')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -825,10 +812,10 @@ describe('DetailHeader', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-btn'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('test-id')
|
||||
@@ -840,10 +827,10 @@ describe('DetailHeader', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-btn'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith(true)
|
||||
@@ -861,10 +848,10 @@ describe('DetailHeader', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-btn'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshModelProviders).toHaveBeenCalled()
|
||||
@@ -876,10 +863,10 @@ describe('DetailHeader', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-btn'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidateAllToolProviders).toHaveBeenCalled()
|
||||
@@ -891,10 +878,10 @@ describe('DetailHeader', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-btn'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(amplitude.trackEvent).toHaveBeenCalledWith('plugin_uninstalled', expect.any(Object))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cloneElement } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginSource } from '../../types'
|
||||
import OperationDropdown from '../operation-dropdown'
|
||||
@@ -12,24 +14,22 @@ vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/action-button', () => ({
|
||||
default: ({ children, className, onClick }: { children: React.ReactNode, className?: string, onClick?: () => void }) => (
|
||||
<button data-testid="action-button" className={className} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children, open }: { children: ReactNode, open: boolean }) => (
|
||||
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
||||
<div data-testid="portal-elem" data-open={open}>{children}</div>
|
||||
DropdownMenuTrigger: ({ children, className }: { children: ReactNode, className?: string }) => (
|
||||
<button data-testid="dropdown-trigger" className={className}>{children}</button>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => (
|
||||
<div data-testid="portal-content" className={className}>{children}</div>
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="dropdown-content">{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({ children, onClick, render, destructive }: { children: ReactNode, onClick?: () => void, render?: ReactElement, destructive?: boolean }) => {
|
||||
if (render)
|
||||
return cloneElement(render, { onClick, 'data-destructive': destructive } as Record<string, unknown>, children)
|
||||
return <div data-testid="dropdown-item" data-destructive={destructive} onClick={onClick}>{children}</div>
|
||||
},
|
||||
DropdownMenuSeparator: () => <hr data-testid="dropdown-separator" />,
|
||||
}))
|
||||
|
||||
describe('OperationDropdown', () => {
|
||||
@@ -52,14 +52,13 @@ describe('OperationDropdown', () => {
|
||||
it('should render trigger button', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('action-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('dropdown-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dropdown content', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('dropdown-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render info option for github source', () => {
|
||||
@@ -118,14 +117,10 @@ describe('OperationDropdown', () => {
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should toggle dropdown when trigger is clicked', () => {
|
||||
it('should render dropdown menu root', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// The portal-elem should reflect the open state
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onInfo when info option is clicked', () => {
|
||||
@@ -174,7 +169,7 @@ describe('OperationDropdown', () => {
|
||||
const { unmount } = render(
|
||||
<OperationDropdown {...defaultProps} source={source} />,
|
||||
)
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.detailPanel.operation.remove')).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
@@ -199,9 +194,7 @@ describe('OperationDropdown', () => {
|
||||
|
||||
describe('Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
// Verify the component is exported as a memo component
|
||||
expect(OperationDropdown).toBeDefined()
|
||||
// React.memo wraps the component, so it should have $$typeof
|
||||
expect((OperationDropdown as { $$typeof?: symbol }).$$typeof).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,24 +9,6 @@ vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ isShow, title, onCancel, onConfirm, isLoading }: {
|
||||
isShow: boolean
|
||||
title: string
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
isLoading: boolean
|
||||
}) => isShow
|
||||
? (
|
||||
<div data-testid="delete-confirm">
|
||||
<div data-testid="delete-title">{title}</div>
|
||||
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="confirm-ok" onClick={onConfirm} disabled={isLoading}>Confirm</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-page/plugin-info', () => ({
|
||||
default: ({ repository, release, packageName, onHide }: {
|
||||
repository: string
|
||||
@@ -230,7 +212,7 @@ describe('HeaderModals', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('delete-confirm')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete confirm when isShowDeleteConfirm is true', () => {
|
||||
@@ -247,7 +229,7 @@ describe('HeaderModals', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show correct delete title', () => {
|
||||
@@ -264,7 +246,7 @@ describe('HeaderModals', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('delete-title')).toHaveTextContent('plugin.action.delete')
|
||||
expect(screen.getByRole('alertdialog')).toHaveTextContent('plugin.action.delete')
|
||||
})
|
||||
|
||||
it('should call hideDeleteConfirm when cancel is clicked', () => {
|
||||
@@ -281,7 +263,7 @@ describe('HeaderModals', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-cancel'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(modalStates.hideDeleteConfirm).toHaveBeenCalled()
|
||||
})
|
||||
@@ -300,7 +282,7 @@ describe('HeaderModals', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
expect(mockOnDelete).toHaveBeenCalled()
|
||||
})
|
||||
@@ -319,7 +301,7 @@ describe('HeaderModals', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('confirm-ok')).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.confirm/ })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -485,7 +467,7 @@ describe('HeaderModals', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-info')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,15 @@ import type { FC } from 'react'
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import type { ModalStates, VersionTarget } from '../hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@/app/components/base/ui/alert-dialog'
|
||||
import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info'
|
||||
import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
@@ -50,7 +58,6 @@ const HeaderModals: FC<HeaderModalsProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Plugin Info Modal */}
|
||||
{isShowPluginInfo && (
|
||||
<PluginInfo
|
||||
repository={isFromGitHub ? meta?.repo : ''}
|
||||
@@ -60,27 +67,35 @@ const HeaderModals: FC<HeaderModalsProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirm Modal */}
|
||||
{isShowDeleteConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
|
||||
content={(
|
||||
<div>
|
||||
<AlertDialog
|
||||
open={isShowDeleteConfirm}
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
hideDeleteConfirm()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent backdropProps={{ forceRender: true }}>
|
||||
<div className="flex flex-col gap-2 px-6 pb-4 pt-6">
|
||||
<AlertDialogTitle className="text-text-primary title-2xl-semi-bold">
|
||||
{t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full whitespace-pre-wrap break-words text-text-tertiary system-md-regular">
|
||||
{t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })}
|
||||
<span className="system-md-semibold">{label[locale]}</span>
|
||||
<span className="text-text-secondary system-md-semibold">{label[locale]}</span>
|
||||
{t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })}
|
||||
<br />
|
||||
</div>
|
||||
)}
|
||||
onCancel={hideDeleteConfirm}
|
||||
onConfirm={onDelete}
|
||||
isLoading={deleting}
|
||||
isDisabled={deleting}
|
||||
/>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton disabled={deleting}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={onDelete}>
|
||||
{t('operation.confirm', { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Update from Marketplace Modal */}
|
||||
{isShowUpdateModal && (
|
||||
<UpdateFromMarketplace
|
||||
pluginId={detail.plugin_id}
|
||||
|
||||
@@ -15,6 +15,7 @@ type VersionPickerMock = {
|
||||
const {
|
||||
mockSetShowUpdatePluginModal,
|
||||
mockRefreshModelProviders,
|
||||
mockInvalidateCheckInstalled,
|
||||
mockInvalidateAllToolProviders,
|
||||
mockUninstallPlugin,
|
||||
mockFetchReleases,
|
||||
@@ -23,6 +24,7 @@ const {
|
||||
return {
|
||||
mockSetShowUpdatePluginModal: vi.fn(),
|
||||
mockRefreshModelProviders: vi.fn(),
|
||||
mockInvalidateCheckInstalled: vi.fn(),
|
||||
mockInvalidateAllToolProviders: vi.fn(),
|
||||
mockUninstallPlugin: vi.fn(() => Promise.resolve({ success: true })),
|
||||
mockFetchReleases: vi.fn(() => Promise.resolve([{ tag_name: 'v2.0.0' }])),
|
||||
@@ -46,6 +48,10 @@ vi.mock('@/service/plugins', () => ({
|
||||
uninstallPlugin: mockUninstallPlugin,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInvalidateCheckInstalled: () => mockInvalidateCheckInstalled,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
|
||||
}))
|
||||
@@ -178,6 +184,7 @@ describe('usePluginOperations', () => {
|
||||
result.current.handleUpdatedFromMarketplace()
|
||||
})
|
||||
|
||||
expect(mockInvalidateCheckInstalled).toHaveBeenCalled()
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
expect(modalStates.hideUpdateModal).toHaveBeenCalled()
|
||||
})
|
||||
@@ -251,6 +258,32 @@ describe('usePluginOperations', () => {
|
||||
expect(mockSetShowUpdatePluginModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invalidate checkInstalled when GitHub update save callback fires', async () => {
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: false,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate()
|
||||
})
|
||||
|
||||
const firstCall = mockSetShowUpdatePluginModal.mock.calls.at(0)?.[0]
|
||||
firstCall?.onSaveCallback()
|
||||
|
||||
expect(mockInvalidateCheckInstalled).toHaveBeenCalled()
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not show modal when no releases found', async () => {
|
||||
mockFetchReleases.mockResolvedValueOnce([])
|
||||
const detail = createPluginDetail({
|
||||
@@ -388,6 +421,7 @@ describe('usePluginOperations', () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(mockInvalidateCheckInstalled).toHaveBeenCalled()
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import type { ModalStates, VersionTarget } from './use-detail-header-state'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { uninstallPlugin } from '@/service/plugins'
|
||||
import { useInvalidateCheckInstalled } from '@/service/use-plugins'
|
||||
import { useInvalidateAllToolProviders } from '@/service/use-tools'
|
||||
import { useGitHubReleases } from '../../../install-plugin/hooks'
|
||||
import { PluginCategoryEnum, PluginSource } from '../../../types'
|
||||
@@ -36,13 +38,19 @@ export const usePluginOperations = ({
|
||||
isFromMarketplace,
|
||||
onUpdate,
|
||||
}: UsePluginOperationsParams): UsePluginOperationsReturn => {
|
||||
const { t } = useTranslation()
|
||||
const { checkForUpdates, fetchReleases } = useGitHubReleases()
|
||||
const { setShowUpdatePluginModal } = useModalContext()
|
||||
const { refreshModelProviders } = useProviderContext()
|
||||
const invalidateCheckInstalled = useInvalidateCheckInstalled()
|
||||
const invalidateAllToolProviders = useInvalidateAllToolProviders()
|
||||
|
||||
const { id, meta, plugin_id } = detail
|
||||
const { author, category, name } = detail.declaration || detail
|
||||
const handlePluginUpdated = useCallback((isDelete?: boolean) => {
|
||||
invalidateCheckInstalled()
|
||||
onUpdate?.(isDelete)
|
||||
}, [invalidateCheckInstalled, onUpdate])
|
||||
|
||||
const handleUpdate = useCallback(async (isDowngrade?: boolean) => {
|
||||
if (isFromMarketplace) {
|
||||
@@ -71,7 +79,7 @@ export const usePluginOperations = ({
|
||||
if (needUpdate) {
|
||||
setShowUpdatePluginModal({
|
||||
onSaveCallback: () => {
|
||||
onUpdate?.()
|
||||
handlePluginUpdated()
|
||||
},
|
||||
payload: {
|
||||
type: PluginSource.github,
|
||||
@@ -97,15 +105,15 @@ export const usePluginOperations = ({
|
||||
checkForUpdates,
|
||||
setShowUpdatePluginModal,
|
||||
detail,
|
||||
onUpdate,
|
||||
handlePluginUpdated,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
])
|
||||
|
||||
const handleUpdatedFromMarketplace = useCallback(() => {
|
||||
onUpdate?.()
|
||||
handlePluginUpdated()
|
||||
modalStates.hideUpdateModal()
|
||||
}, [onUpdate, modalStates])
|
||||
}, [handlePluginUpdated, modalStates])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
modalStates.showDeleting()
|
||||
@@ -114,7 +122,11 @@ export const usePluginOperations = ({
|
||||
|
||||
if (res.success) {
|
||||
modalStates.hideDeleteConfirm()
|
||||
onUpdate?.(true)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('action.deleteSuccess', { ns: 'plugin' }),
|
||||
})
|
||||
handlePluginUpdated(true)
|
||||
|
||||
if (PluginCategoryEnum.model.includes(category))
|
||||
refreshModelProviders()
|
||||
@@ -130,7 +142,7 @@ export const usePluginOperations = ({
|
||||
plugin_id,
|
||||
name,
|
||||
modalStates,
|
||||
onUpdate,
|
||||
handlePluginUpdated,
|
||||
refreshModelProviders,
|
||||
invalidateAllToolProviders,
|
||||
])
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiArrowRightUpLine, RiMoreFill } from '@remixicon/react'
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
// import Button from '@/app/components/base/button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { PluginSource } from '../types'
|
||||
@@ -21,6 +20,10 @@ type Props = {
|
||||
onCheckVersion: () => void
|
||||
onRemove: () => void
|
||||
detailUrl: string
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
popupClassName?: string
|
||||
}
|
||||
|
||||
const OperationDropdown: FC<Props> = ({
|
||||
@@ -29,83 +32,52 @@ const OperationDropdown: FC<Props> = ({
|
||||
onInfo,
|
||||
onCheckVersion,
|
||||
onRemove,
|
||||
placement = 'bottom-end',
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
popupClassName,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, doSetOpen] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
doSetOpen(v)
|
||||
openRef.current = v
|
||||
}, [doSetOpen])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: -12,
|
||||
crossAxis: 36,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<div>
|
||||
<ActionButton className={cn(open && 'bg-state-base-hover')}>
|
||||
<RiMoreFill className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div className="w-[160px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
|
||||
{source === PluginSource.github && (
|
||||
<div
|
||||
onClick={() => {
|
||||
onInfo()
|
||||
handleTrigger()
|
||||
}}
|
||||
className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
{t('detailPanel.operation.info', { ns: 'plugin' })}
|
||||
</div>
|
||||
)}
|
||||
{source === PluginSource.github && (
|
||||
<div
|
||||
onClick={() => {
|
||||
onCheckVersion()
|
||||
handleTrigger()
|
||||
}}
|
||||
className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
{t('detailPanel.operation.checkUpdate', { ns: 'plugin' })}
|
||||
</div>
|
||||
)}
|
||||
{(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && (
|
||||
<a href={detailUrl} target="_blank" className="system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover">
|
||||
<span className="grow">{t('detailPanel.operation.viewDetail', { ns: 'plugin' })}</span>
|
||||
<RiArrowRightUpLine className="h-3.5 w-3.5 shrink-0 text-text-tertiary" />
|
||||
</a>
|
||||
)}
|
||||
{(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && (
|
||||
<div className="my-1 h-px bg-divider-subtle"></div>
|
||||
)}
|
||||
<div
|
||||
onClick={() => {
|
||||
onRemove()
|
||||
handleTrigger()
|
||||
}}
|
||||
className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive"
|
||||
>
|
||||
{t('detailPanel.operation.remove', { ns: 'plugin' })}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn('action-btn action-btn-m', open && 'bg-state-base-hover')}
|
||||
>
|
||||
<span className="i-ri-more-fill h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
popupClassName={cn('w-[160px]', popupClassName)}
|
||||
>
|
||||
{source === PluginSource.github && (
|
||||
<DropdownMenuItem onClick={onInfo}>
|
||||
{t('detailPanel.operation.info', { ns: 'plugin' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{source === PluginSource.github && (
|
||||
<DropdownMenuItem onClick={onCheckVersion}>
|
||||
{t('detailPanel.operation.checkUpdate', { ns: 'plugin' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && (
|
||||
<DropdownMenuItem render={<a href={detailUrl} target="_blank" rel="noopener noreferrer" />}>
|
||||
<span className="grow">{t('detailPanel.operation.viewDetail', { ns: 'plugin' })}</span>
|
||||
<span className="i-ri-arrow-right-up-line h-3.5 w-3.5 shrink-0 text-text-tertiary" />
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
<DropdownMenuItem destructive onClick={onRemove}>
|
||||
{t('detailPanel.operation.remove', { ns: 'plugin' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
export default React.memo(OperationDropdown)
|
||||
|
||||
@@ -104,36 +104,6 @@ vi.mock('../../install-plugin/install-from-github', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Portal components for PluginVersionPicker
|
||||
let mockPortalOpen = false
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: {
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) => {
|
||||
mockPortalOpen = open
|
||||
return <div data-testid="portal-elem" data-open={open}>{children}</div>
|
||||
},
|
||||
PortalToFollowElemTrigger: ({ children, onClick, className }: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
className?: string
|
||||
}) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children, className }: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => {
|
||||
if (!mockPortalOpen)
|
||||
return null
|
||||
return <div data-testid="portal-content" className={className}>{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock semver
|
||||
vi.mock('semver', () => ({
|
||||
lt: (v1: string, v2: string) => {
|
||||
@@ -247,7 +217,6 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
describe('update-plugin', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpen = false
|
||||
mockCheck.mockResolvedValue({ status: TaskStatus.success })
|
||||
})
|
||||
|
||||
@@ -946,7 +915,7 @@ describe('update-plugin', () => {
|
||||
onShowChange: vi.fn(),
|
||||
pluginID: 'test-plugin-id',
|
||||
currentVersion: '1.0.0',
|
||||
trigger: <button>Select Version</button>,
|
||||
trigger: <span>Select Version</span>,
|
||||
onSelect: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -964,7 +933,7 @@ describe('update-plugin', () => {
|
||||
render(<PluginVersionPicker {...defaultProps} isShow={false} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('plugin.detailPanel.switchVersion')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render version list when isShow is true', () => {
|
||||
@@ -972,7 +941,6 @@ describe('update-plugin', () => {
|
||||
render(<PluginVersionPicker {...defaultProps} isShow={true} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1002,7 +970,7 @@ describe('update-plugin', () => {
|
||||
|
||||
// Act
|
||||
render(<PluginVersionPicker {...defaultProps} onShowChange={onShowChange} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByText('Select Version'))
|
||||
|
||||
// Assert
|
||||
expect(onShowChange).toHaveBeenCalledWith(true)
|
||||
@@ -1014,7 +982,7 @@ describe('update-plugin', () => {
|
||||
|
||||
// Act
|
||||
render(<PluginVersionPicker {...defaultProps} disabled={true} onShowChange={onShowChange} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByText('Select Version'))
|
||||
|
||||
// Assert
|
||||
expect(onShowChange).not.toHaveBeenCalled()
|
||||
@@ -1116,7 +1084,7 @@ describe('update-plugin', () => {
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should support custom offset', () => {
|
||||
@@ -1125,12 +1093,13 @@ describe('update-plugin', () => {
|
||||
<PluginVersionPicker
|
||||
{...defaultProps}
|
||||
isShow={true}
|
||||
offset={{ mainAxis: 10, crossAxis: 20 }}
|
||||
sideOffset={10}
|
||||
alignOffset={20}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1190,7 +1159,7 @@ describe('update-plugin', () => {
|
||||
onShowChange: vi.fn(),
|
||||
pluginID: 'test',
|
||||
currentVersion: '1.0.0',
|
||||
trigger: <button>Select</button>,
|
||||
trigger: <span>Select</span>,
|
||||
onSelect: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
|
||||
@@ -18,8 +18,8 @@ const DowngradeWarningModal = ({
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start gap-2 self-stretch">
|
||||
<div className="title-2xl-semi-bold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'plugin' })}</div>
|
||||
<div className="system-md-regular text-text-secondary">
|
||||
<div className="text-text-primary title-2xl-semi-bold">{t(`${i18nPrefix}.title`, { ns: 'plugin' })}</div>
|
||||
<div className="text-text-secondary system-md-regular">
|
||||
{t(`${i18nPrefix}.description`, { ns: 'plugin' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge, { BadgeState } from '@/app/components/base/badge/index'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/app/components/base/ui/dialog'
|
||||
import Card from '@/app/components/plugins/card'
|
||||
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
|
||||
import { pluginManifestToCardPluginProps } from '@/app/components/plugins/install-plugin/utils'
|
||||
@@ -125,63 +130,65 @@ const UpdatePluginModal: FC<Props> = ({
|
||||
const doShowDowngradeWarningModal = isShowDowngradeWarningModal && uploadStep === UploadStep.notStarted
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={true}
|
||||
onClose={onCancel}
|
||||
className={cn('min-w-[560px]', doShowDowngradeWarningModal && 'min-w-[640px]')}
|
||||
closable
|
||||
title={!doShowDowngradeWarningModal && t(`${i18nPrefix}.${uploadStep === UploadStep.installed ? 'successfulTitle' : 'title'}`, { ns: 'plugin' })}
|
||||
>
|
||||
{doShowDowngradeWarningModal && (
|
||||
<DowngradeWarningModal
|
||||
onCancel={onCancel}
|
||||
onJustDowngrade={handleConfirm}
|
||||
onExcludeAndDowngrade={handleExcludeAndDownload}
|
||||
/>
|
||||
)}
|
||||
{!doShowDowngradeWarningModal && (
|
||||
<>
|
||||
<div className="system-md-regular mb-2 mt-3 text-text-secondary">
|
||||
{t(`${i18nPrefix}.description`, { ns: 'plugin' })}
|
||||
</div>
|
||||
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">
|
||||
<Card
|
||||
installed={uploadStep === UploadStep.installed}
|
||||
payload={pluginManifestToCardPluginProps({
|
||||
...originalPackageInfo.payload,
|
||||
icon: icon!,
|
||||
})}
|
||||
className="w-full"
|
||||
titleLeft={(
|
||||
<>
|
||||
<Badge className="mx-1" size="s" state={BadgeState.Warning}>
|
||||
{`${originalPackageInfo.payload.version} -> ${targetPackageInfo.version}`}
|
||||
</Badge>
|
||||
</>
|
||||
<Dialog open onOpenChange={() => onCancel()}>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className={cn('min-w-[560px]', doShowDowngradeWarningModal && 'min-w-[640px]')}
|
||||
>
|
||||
<DialogCloseButton />
|
||||
{doShowDowngradeWarningModal && (
|
||||
<DowngradeWarningModal
|
||||
onCancel={onCancel}
|
||||
onJustDowngrade={handleConfirm}
|
||||
onExcludeAndDowngrade={handleExcludeAndDownload}
|
||||
/>
|
||||
)}
|
||||
{!doShowDowngradeWarningModal && (
|
||||
<>
|
||||
<DialogTitle className="text-text-primary title-2xl-semi-bold">
|
||||
{t(`${i18nPrefix}.${uploadStep === UploadStep.installed ? 'successfulTitle' : 'title'}`, { ns: 'plugin' })}
|
||||
</DialogTitle>
|
||||
<div className="mb-2 mt-3 text-text-secondary system-md-regular">
|
||||
{t(`${i18nPrefix}.description`, { ns: 'plugin' })}
|
||||
</div>
|
||||
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">
|
||||
<Card
|
||||
installed={uploadStep === UploadStep.installed}
|
||||
payload={pluginManifestToCardPluginProps({
|
||||
...originalPackageInfo.payload,
|
||||
icon: icon!,
|
||||
})}
|
||||
className="w-full"
|
||||
titleLeft={(
|
||||
<>
|
||||
<Badge className="mx-1" size="s" state={BadgeState.Warning}>
|
||||
{`${originalPackageInfo.payload.version} -> ${targetPackageInfo.version}`}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 self-stretch pt-5">
|
||||
{uploadStep === UploadStep.notStarted && (
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 self-stretch pt-5">
|
||||
{uploadStep === UploadStep.notStarted && (
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
variant="primary"
|
||||
loading={uploadStep === UploadStep.upgrading}
|
||||
onClick={handleConfirm}
|
||||
disabled={uploadStep === UploadStep.upgrading}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
{configBtnText}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={uploadStep === UploadStep.upgrading}
|
||||
onClick={handleConfirm}
|
||||
disabled={uploadStep === UploadStep.upgrading}
|
||||
>
|
||||
{configBtnText}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</Modal>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
export default React.memo(UpdatePluginModal)
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
'use client'
|
||||
import type {
|
||||
OffsetOptions,
|
||||
Placement,
|
||||
} from '@floating-ui/react'
|
||||
import type { FC } from 'react'
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { lt } from 'semver'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { useVersionListOfPlugin } from '@/service/use-plugins'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -26,7 +23,8 @@ type Props = {
|
||||
currentVersion: string
|
||||
trigger: React.ReactNode
|
||||
placement?: Placement
|
||||
offset?: OffsetOptions
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
onSelect: ({
|
||||
version,
|
||||
unique_identifier,
|
||||
@@ -46,22 +44,14 @@ const PluginVersionPicker: FC<Props> = ({
|
||||
currentVersion,
|
||||
trigger,
|
||||
placement = 'bottom-start',
|
||||
offset = {
|
||||
mainAxis: 4,
|
||||
crossAxis: -16,
|
||||
},
|
||||
sideOffset = 4,
|
||||
alignOffset = -16,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const format = t('dateTimeFormat', { ns: 'appLog' }).split(' ')[0]
|
||||
const { formatDate } = useTimestamp()
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
if (disabled)
|
||||
return
|
||||
onShowChange(true)
|
||||
}
|
||||
|
||||
const { data: res } = useVersionListOfPlugin(pluginID)
|
||||
|
||||
const handleSelect = useCallback(({ version, unique_identifier, isDowngrade }: {
|
||||
@@ -76,49 +66,52 @@ const PluginVersionPicker: FC<Props> = ({
|
||||
}, [currentVersion, onSelect, onShowChange])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
<Popover
|
||||
open={isShow}
|
||||
onOpenChange={onShowChange}
|
||||
onOpenChange={(open) => {
|
||||
if (!disabled)
|
||||
onShowChange(open)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
<PopoverTrigger
|
||||
className={cn('inline-flex cursor-pointer items-center', disabled && 'cursor-default')}
|
||||
onClick={handleTriggerClick}
|
||||
>
|
||||
{trigger}
|
||||
</PortalToFollowElemTrigger>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PortalToFollowElemContent className="z-[1000]">
|
||||
<div className="relative w-[209px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm">
|
||||
<div className="system-xs-medium-uppercase px-3 pb-0.5 pt-1 text-text-tertiary">
|
||||
{t('detailPanel.switchVersion', { ns: 'plugin' })}
|
||||
</div>
|
||||
<div className="relative">
|
||||
{res?.data.versions.map(version => (
|
||||
<div
|
||||
key={version.unique_identifier}
|
||||
className={cn(
|
||||
'flex h-7 cursor-pointer items-center gap-1 rounded-lg px-3 py-1 hover:bg-state-base-hover',
|
||||
currentVersion === version.version && 'cursor-default opacity-30 hover:bg-transparent',
|
||||
)}
|
||||
onClick={() => handleSelect({
|
||||
version: version.version,
|
||||
unique_identifier: version.unique_identifier,
|
||||
isDowngrade: lt(version.version, currentVersion),
|
||||
})}
|
||||
>
|
||||
<div className="flex grow items-center">
|
||||
<div className="system-sm-medium text-text-secondary">{version.version}</div>
|
||||
{currentVersion === version.version && <Badge className="ml-1" text="CURRENT" />}
|
||||
</div>
|
||||
<div className="system-xs-regular shrink-0 text-text-tertiary">{formatDate(version.created_at, format)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
popupClassName="relative w-[209px] bg-components-panel-bg-blur p-1 backdrop-blur-sm"
|
||||
>
|
||||
<div className="px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase">
|
||||
{t('detailPanel.switchVersion', { ns: 'plugin' })}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<div className="relative max-h-[224px] overflow-y-auto">
|
||||
{res?.data.versions.map(version => (
|
||||
<div
|
||||
key={version.unique_identifier}
|
||||
className={cn(
|
||||
'flex h-7 cursor-pointer items-center gap-1 rounded-lg px-3 py-1 hover:bg-state-base-hover',
|
||||
currentVersion === version.version && 'cursor-default opacity-30 hover:bg-transparent',
|
||||
)}
|
||||
onClick={() => handleSelect({
|
||||
version: version.version,
|
||||
unique_identifier: version.unique_identifier,
|
||||
isDowngrade: lt(version.version, currentVersion),
|
||||
})}
|
||||
>
|
||||
<div className="flex grow items-center">
|
||||
<div className="text-text-secondary system-sm-medium">{version.version}</div>
|
||||
{currentVersion === version.version && <Badge className="ml-1" text="CURRENT" />}
|
||||
</div>
|
||||
<div className="shrink-0 text-text-tertiary system-xs-regular">{formatDate(version.created_at, format)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -266,8 +266,8 @@ export const ModalContextProvider = ({
|
||||
accountSettingTab && (
|
||||
<AccountSetting
|
||||
activeTab={accountSettingTab}
|
||||
onCancel={handleCancelAccountSettingModal}
|
||||
onTabChange={handleAccountSettingTabChange}
|
||||
onCancelAction={handleCancelAccountSettingModal}
|
||||
onTabChangeAction={handleAccountSettingTabChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
33
web/contract/console/model-providers.ts
Normal file
33
web/contract/console/model-providers.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ModelItem, PreferredProviderTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import { type } from '@orpc/contract'
|
||||
import { base } from '../base'
|
||||
|
||||
export const modelProvidersModelsContract = base
|
||||
.route({
|
||||
path: '/workspaces/current/model-providers/{provider}/models',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{
|
||||
params: {
|
||||
provider: string
|
||||
}
|
||||
}>())
|
||||
.output(type<{
|
||||
data: ModelItem[]
|
||||
}>())
|
||||
|
||||
export const changePreferredProviderTypeContract = base
|
||||
.route({
|
||||
path: '/workspaces/current/model-providers/{provider}/preferred-provider-type',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(type<{
|
||||
params: {
|
||||
provider: string
|
||||
}
|
||||
body: {
|
||||
preferred_provider_type: PreferredProviderTypeEnum
|
||||
}
|
||||
}>())
|
||||
.output(type<CommonResponse>())
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
exploreInstalledAppsContract,
|
||||
exploreInstalledAppUninstallContract,
|
||||
} from './console/explore'
|
||||
import { changePreferredProviderTypeContract, modelProvidersModelsContract } from './console/model-providers'
|
||||
import { systemFeaturesContract } from './console/system'
|
||||
import {
|
||||
triggerOAuthConfigContract,
|
||||
@@ -63,6 +64,10 @@ export const consoleRouterContract = {
|
||||
parameters: trialAppParametersContract,
|
||||
workflows: trialAppWorkflowsContract,
|
||||
},
|
||||
modelProviders: {
|
||||
models: modelProvidersModelsContract,
|
||||
changePreferredProviderType: changePreferredProviderTypeContract,
|
||||
},
|
||||
billing: {
|
||||
invoices: invoicesContract,
|
||||
bindPartnerStack: bindPartnerStackContract,
|
||||
|
||||
@@ -2969,16 +2969,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/billing/pricing/header.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/billing/pricing/index.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/billing/pricing/plan-switcher/plan-range-switcher.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
@@ -4586,11 +4576,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/index.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/key-validator/declarations.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -4669,11 +4654,8 @@
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/hooks.ts": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx": {
|
||||
@@ -4702,14 +4684,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 3
|
||||
@@ -4757,11 +4731,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/model-badge/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/model-modal/Form.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
@@ -4779,15 +4748,6 @@
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/model-modal/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 6
|
||||
},
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
@@ -4891,11 +4851,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/provider-added-card/add-model-button.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -4904,25 +4859,9 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx": {
|
||||
@@ -4957,24 +4896,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/provider-icon/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/plugin-page/utils.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 4
|
||||
@@ -5372,11 +5298,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -5466,14 +5387,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/plugins/plugin-detail-panel/operation-dropdown.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"app/components/plugins/plugin-detail-panel/strategy-detail.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 11
|
||||
@@ -5807,27 +5720,6 @@
|
||||
"count": 30
|
||||
}
|
||||
},
|
||||
"app/components/plugins/update-plugin/downgrade-warning.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/plugins/update-plugin/from-market-place.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/plugins/update-plugin/plugin-version-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/rag-pipeline/components/chunk-card-list/chunk-card.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
|
||||
@@ -409,12 +409,10 @@
|
||||
"modelProvider.selector.tip": "تمت إزالة هذا النموذج. يرجى إضافة نموذج أو تحديد نموذج آخر.",
|
||||
"modelProvider.setupModelFirst": "يرجى إعداد نموذجك أولاً",
|
||||
"modelProvider.showModels": "عرض النماذج",
|
||||
"modelProvider.showModelsNum": "عرض {{num}} نماذج",
|
||||
"modelProvider.showMoreModelProvider": "عرض المزيد من مزودي النماذج",
|
||||
"modelProvider.speechToTextModel.key": "نموذج تحويل الكلام إلى نص",
|
||||
"modelProvider.speechToTextModel.tip": "تعيين النموذج الافتراضي لإدخال تحويل الكلام إلى نص في المحادثة.",
|
||||
"modelProvider.systemModelSettings": "إعدادات نموذج النظام",
|
||||
"modelProvider.systemModelSettingsLink": "لماذا من الضروري إعداد نموذج النظام؟",
|
||||
"modelProvider.systemReasoningModel.key": "نموذج التفكير النظامي",
|
||||
"modelProvider.systemReasoningModel.tip": "تعيين نموذج الاستنتاج الافتراضي لاستخدامه لإنشاء التطبيقات، بالإضافة إلى ميزات مثل إنشاء اسم الحوار واقتراح السؤال التالي ستستخدم أيضًا نموذج الاستنتاج الافتراضي.",
|
||||
"modelProvider.toBeConfigured": "ليتم تكوينه",
|
||||
|
||||
@@ -409,12 +409,10 @@
|
||||
"modelProvider.selector.tip": "Dieses Modell wurde entfernt. Bitte fügen Sie ein Modell hinzu oder wählen Sie ein anderes Modell.",
|
||||
"modelProvider.setupModelFirst": "Bitte richten Sie zuerst Ihr Modell ein",
|
||||
"modelProvider.showModels": "Modelle anzeigen",
|
||||
"modelProvider.showModelsNum": "Zeige {{num}} Modelle",
|
||||
"modelProvider.showMoreModelProvider": "Zeige mehr Modellanbieter",
|
||||
"modelProvider.speechToTextModel.key": "Sprach-zu-Text-Modell",
|
||||
"modelProvider.speechToTextModel.tip": "Legen Sie das Standardmodell für die Spracheingabe in Konversationen fest.",
|
||||
"modelProvider.systemModelSettings": "Systemmodell-Einstellungen",
|
||||
"modelProvider.systemModelSettingsLink": "Warum ist es notwendig, ein Systemmodell einzurichten?",
|
||||
"modelProvider.systemReasoningModel.key": "System-Reasoning-Modell",
|
||||
"modelProvider.systemReasoningModel.tip": "Legen Sie das Standardinferenzmodell fest, das für die Erstellung von Anwendungen verwendet wird, sowie Funktionen wie die Generierung von Dialognamen und die Vorschlagserstellung für die nächste Frage, die auch das Standardinferenzmodell verwenden.",
|
||||
"modelProvider.toBeConfigured": "Zu konfigurieren",
|
||||
|
||||
@@ -340,19 +340,38 @@
|
||||
"modelProvider.auth.unAuthorized": "Unauthorized",
|
||||
"modelProvider.buyQuota": "Buy Quota",
|
||||
"modelProvider.callTimes": "Call times",
|
||||
"modelProvider.card.aiCreditsInUse": "AI credits in use",
|
||||
"modelProvider.card.aiCreditsOption": "AI credits",
|
||||
"modelProvider.card.apiKeyOption": "API Key",
|
||||
"modelProvider.card.apiKeyRequired": "API key required",
|
||||
"modelProvider.card.apiKeyUnavailableFallback": "API Key unavailable, now using AI credits",
|
||||
"modelProvider.card.apiKeyUnavailableFallbackDescription": "Check your API key configuration to switch back",
|
||||
"modelProvider.card.buyQuota": "Buy Quota",
|
||||
"modelProvider.card.callTimes": "Call times",
|
||||
"modelProvider.card.creditsExhaustedDescription": "Please <upgradeLink>upgrade your plan</upgradeLink> or configure an API key",
|
||||
"modelProvider.card.creditsExhaustedFallback": "AI credits exhausted, now using API key",
|
||||
"modelProvider.card.creditsExhaustedFallbackDescription": "<upgradeLink>Upgrade your plan</upgradeLink> to resume AI credit priority.",
|
||||
"modelProvider.card.creditsExhaustedMessage": "AI credits have been exhausted",
|
||||
"modelProvider.card.modelAPI": "{{modelName}} models are using the API Key.",
|
||||
"modelProvider.card.modelNotSupported": "{{modelName}} models are not installed.",
|
||||
"modelProvider.card.modelSupported": "{{modelName}} models are using this quota.",
|
||||
"modelProvider.card.modelNotSupported": "{{modelName}} not installed",
|
||||
"modelProvider.card.modelSupported": "{{modelName}} models are using these credits.",
|
||||
"modelProvider.card.noApiKeysDescription": "Add an API key to start using your own model credentials.",
|
||||
"modelProvider.card.noApiKeysFallback": "No API keys, using AI credits instead",
|
||||
"modelProvider.card.noApiKeysTitle": "No API keys configured yet",
|
||||
"modelProvider.card.noAvailableUsage": "No available usage",
|
||||
"modelProvider.card.onTrial": "On Trial",
|
||||
"modelProvider.card.paid": "Paid",
|
||||
"modelProvider.card.priorityUse": "Priority use",
|
||||
"modelProvider.card.quota": "QUOTA",
|
||||
"modelProvider.card.quotaExhausted": "Quota exhausted",
|
||||
"modelProvider.card.quotaExhausted": "Credits exhausted",
|
||||
"modelProvider.card.removeKey": "Remove API Key",
|
||||
"modelProvider.card.tip": "Message Credits supports models from {{modelNames}}. Priority will be given to the paid quota. The free quota will be used after the paid quota is exhausted.",
|
||||
"modelProvider.card.tip": "AI Credits supports models from {{modelNames}}. Priority will be given to the paid quota. The Trial quota will be used after the paid quota is exhausted.",
|
||||
"modelProvider.card.tokens": "Tokens",
|
||||
"modelProvider.card.unavailable": "Unavailable",
|
||||
"modelProvider.card.upgradePlan": "upgrade your plan",
|
||||
"modelProvider.card.usageLabel": "Usage",
|
||||
"modelProvider.card.usagePriority": "Usage Priority",
|
||||
"modelProvider.card.usagePriorityTip": "Set which resource to use first when running models.",
|
||||
"modelProvider.collapse": "Collapse",
|
||||
"modelProvider.config": "Config",
|
||||
"modelProvider.configLoadBalancing": "Config Load Balancing",
|
||||
@@ -390,13 +409,14 @@
|
||||
"modelProvider.models": "Models",
|
||||
"modelProvider.modelsNum": "{{num}} Models",
|
||||
"modelProvider.noModelFound": "No model found for {{model}}",
|
||||
"modelProvider.noneConfigured": "Configure a default system model to run applications",
|
||||
"modelProvider.notConfigured": "The system model has not yet been fully configured",
|
||||
"modelProvider.parameters": "PARAMETERS",
|
||||
"modelProvider.parametersInvalidRemoved": "Some parameters are invalid and have been removed",
|
||||
"modelProvider.priorityUsing": "Prioritize using",
|
||||
"modelProvider.providerManaged": "Provider managed",
|
||||
"modelProvider.providerManagedDescription": "Use the single set of credentials provided by the model provider.",
|
||||
"modelProvider.quota": "Quota",
|
||||
"modelProvider.quota": "AI Credits",
|
||||
"modelProvider.quotaTip": "Remaining available free tokens",
|
||||
"modelProvider.rerankModel.key": "Rerank Model",
|
||||
"modelProvider.rerankModel.tip": "Rerank model will reorder the candidate document list based on the semantic match with user query, improving the results of semantic ranking",
|
||||
@@ -409,12 +429,10 @@
|
||||
"modelProvider.selector.tip": "This model has been removed. Please add a model or select another model.",
|
||||
"modelProvider.setupModelFirst": "Please set up your model first",
|
||||
"modelProvider.showModels": "Show Models",
|
||||
"modelProvider.showModelsNum": "Show {{num}} Models",
|
||||
"modelProvider.showMoreModelProvider": "Show more model provider",
|
||||
"modelProvider.speechToTextModel.key": "Speech-to-Text Model",
|
||||
"modelProvider.speechToTextModel.tip": "Set the default model for speech-to-text input in conversation.",
|
||||
"modelProvider.systemModelSettings": "System Model Settings",
|
||||
"modelProvider.systemModelSettingsLink": "Why is it necessary to set up a system model?",
|
||||
"modelProvider.systemModelSettings": "Default Model Settings",
|
||||
"modelProvider.systemReasoningModel.key": "System Reasoning Model",
|
||||
"modelProvider.systemReasoningModel.tip": "Set the default inference model to be used for creating applications, as well as features such as dialogue name generation and next question suggestion will also use the default inference model.",
|
||||
"modelProvider.toBeConfigured": "To be configured",
|
||||
@@ -430,7 +448,7 @@
|
||||
"operation.change": "Change",
|
||||
"operation.clear": "Clear",
|
||||
"operation.close": "Close",
|
||||
"operation.config": "Config",
|
||||
"operation.config": "Configure",
|
||||
"operation.confirm": "Confirm",
|
||||
"operation.confirmAction": "Please confirm your action.",
|
||||
"operation.copied": "Copied",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"action.delete": "Remove plugin",
|
||||
"action.deleteContentLeft": "Would you like to remove ",
|
||||
"action.deleteContentRight": " plugin?",
|
||||
"action.deleteSuccess": "Plugin removed successfully",
|
||||
"action.pluginInfo": "Plugin info",
|
||||
"action.usedInApps": "This plugin is being used in {{num}} apps.",
|
||||
"allCategories": "All Categories",
|
||||
@@ -114,7 +115,7 @@
|
||||
"detailPanel.operation.install": "Install",
|
||||
"detailPanel.operation.remove": "Remove",
|
||||
"detailPanel.operation.update": "Update",
|
||||
"detailPanel.operation.viewDetail": "View Detail",
|
||||
"detailPanel.operation.viewDetail": "View on Marketplace",
|
||||
"detailPanel.serviceOk": "Service OK",
|
||||
"detailPanel.strategyNum": "{{num}} {{strategy}} INCLUDED",
|
||||
"detailPanel.switchVersion": "Switch Version",
|
||||
|
||||
@@ -409,12 +409,10 @@
|
||||
"modelProvider.selector.tip": "Este modelo ha sido eliminado. Por favor agrega un modelo o selecciona otro modelo.",
|
||||
"modelProvider.setupModelFirst": "Por favor configura tu modelo primero",
|
||||
"modelProvider.showModels": "Mostrar Modelos",
|
||||
"modelProvider.showModelsNum": "Mostrar {{num}} Modelos",
|
||||
"modelProvider.showMoreModelProvider": "Mostrar más proveedores de modelos",
|
||||
"modelProvider.speechToTextModel.key": "Modelo de Voz a Texto",
|
||||
"modelProvider.speechToTextModel.tip": "Establece el modelo predeterminado para la entrada de voz a texto en la conversación.",
|
||||
"modelProvider.systemModelSettings": "Configuraciones del Modelo del Sistema",
|
||||
"modelProvider.systemModelSettingsLink": "¿Por qué es necesario configurar un modelo del sistema?",
|
||||
"modelProvider.systemReasoningModel.key": "Modelo de Razonamiento del Sistema",
|
||||
"modelProvider.systemReasoningModel.tip": "Establece el modelo de inferencia predeterminado para ser usado en la creación de aplicaciones, así como características como la generación de nombres de diálogo y sugerencias de la próxima pregunta también usarán el modelo de inferencia predeterminado.",
|
||||
"modelProvider.toBeConfigured": "A configurar",
|
||||
|
||||
@@ -409,12 +409,10 @@
|
||||
"modelProvider.selector.tip": "این مدل حذف شده است. لطفاً یک مدل اضافه کنید یا مدل دیگری را انتخاب کنید.",
|
||||
"modelProvider.setupModelFirst": "لطفاً ابتدا مدل خود را تنظیم کنید",
|
||||
"modelProvider.showModels": "نمایش مدلها",
|
||||
"modelProvider.showModelsNum": "نمایش {{num}} مدل",
|
||||
"modelProvider.showMoreModelProvider": "نمایش ارائهدهندگان مدل بیشتر",
|
||||
"modelProvider.speechToTextModel.key": "مدل تبدیل گفتار به متن",
|
||||
"modelProvider.speechToTextModel.tip": "مدل پیشفرض را برای ورودی گفتار به متن در مکالمه تنظیم کنید.",
|
||||
"modelProvider.systemModelSettings": "تنظیمات مدل سیستم",
|
||||
"modelProvider.systemModelSettingsLink": "چرا تنظیم مدل سیستم ضروری است؟",
|
||||
"modelProvider.systemReasoningModel.key": "مدل استدلال سیستم",
|
||||
"modelProvider.systemReasoningModel.tip": "مدل استنتاج پیشفرض را برای ایجاد برنامهها تنظیم کنید. ویژگیهایی مانند تولید نام گفتگو و پیشنهاد سوال بعدی نیز از مدل استنتاج پیشفرض استفاده خواهند کرد.",
|
||||
"modelProvider.toBeConfigured": "پیکربندی شود",
|
||||
|
||||
@@ -409,12 +409,10 @@
|
||||
"modelProvider.selector.tip": "Ce modèle a été supprimé. Veuillez ajouter un modèle ou sélectionner un autre modèle.",
|
||||
"modelProvider.setupModelFirst": "Veuillez d'abord configurer votre modèle",
|
||||
"modelProvider.showModels": "Montrer les modèles",
|
||||
"modelProvider.showModelsNum": "Afficher {{num}} Modèles",
|
||||
"modelProvider.showMoreModelProvider": "Montrer plus de fournisseur de modèle",
|
||||
"modelProvider.speechToTextModel.key": "Modèle de Texte-à-Parole",
|
||||
"modelProvider.speechToTextModel.tip": "Définissez le modèle par défaut pour l'entrée de texte par la parole dans la conversation.",
|
||||
"modelProvider.systemModelSettings": "Paramètres du Modèle Système",
|
||||
"modelProvider.systemModelSettingsLink": "Pourquoi est-il nécessaire de mettre en place un modèle de système ?",
|
||||
"modelProvider.systemReasoningModel.key": "Modèle de Raisonnement du Système",
|
||||
"modelProvider.systemReasoningModel.tip": "Définissez le modèle d'inférence par défaut à utiliser pour la création d'applications, ainsi que des fonctionnalités telles que la génération de noms de dialogue et la suggestion de la prochaine question utiliseront également le modèle d'inférence par défaut.",
|
||||
"modelProvider.toBeConfigured": "À configurer",
|
||||
|
||||
@@ -409,12 +409,10 @@
|
||||
"modelProvider.selector.tip": "इस मॉडल को हटा दिया गया है। कृपया एक मॉडल जोड़ें या किसी अन्य मॉडल का चयन करें।",
|
||||
"modelProvider.setupModelFirst": "कृपया पहले अपना मॉडल सेट करें",
|
||||
"modelProvider.showModels": "मॉडल्स दिखाएं",
|
||||
"modelProvider.showModelsNum": "{{num}} मॉडल्स दिखाएं",
|
||||
"modelProvider.showMoreModelProvider": "अधिक मॉडल प्रदाता दिखाएं",
|
||||
"modelProvider.speechToTextModel.key": "भाषण-से-पाठ मॉडल",
|
||||
"modelProvider.speechToTextModel.tip": "संवाद में भाषण-से-पाठ इनपुट के लिए डिफ़ॉल्ट मॉडल सेट करें।",
|
||||
"modelProvider.systemModelSettings": "सिस्टम मॉडल सेटिंग्स",
|
||||
"modelProvider.systemModelSettingsLink": "सिस्टम मॉडल सेट करना क्यों आवश्यक है?",
|
||||
"modelProvider.systemReasoningModel.key": "सिस्टम तर्क मॉडल",
|
||||
"modelProvider.systemReasoningModel.tip": "ऐप्लिकेशन बनाने के लिए उपयोग किए जाने वाले डिफ़ॉल्ट अनुमान मॉडल को सेट करें, साथ ही संवाद नाम पीढ़ी और अगले प्रश्न सुझाव जैसी सुविधाएँ भी डिफ़ॉल्ट अनुमान मॉडल का उपयोग करेंगी।",
|
||||
"modelProvider.toBeConfigured": "कॉन्फ़िगर किया जाना है",
|
||||
|
||||
@@ -409,12 +409,10 @@
|
||||
"modelProvider.selector.tip": "Model ini telah dihapus. Silakan tambahkan model atau pilih model lain.",
|
||||
"modelProvider.setupModelFirst": "Silakan atur model Anda terlebih dahulu",
|
||||
"modelProvider.showModels": "Tampilkan Model",
|
||||
"modelProvider.showModelsNum": "Tampilkan {{num}} Model",
|
||||
"modelProvider.showMoreModelProvider": "Tampilkan lebih banyak penyedia model",
|
||||
"modelProvider.speechToTextModel.key": "Model Ucapan-ke-Teks",
|
||||
"modelProvider.speechToTextModel.tip": "Atur model default untuk input ucapan-ke-teks dalam percakapan.",
|
||||
"modelProvider.systemModelSettings": "Pengaturan Model Sistem",
|
||||
"modelProvider.systemModelSettingsLink": "Mengapa perlu menyiapkan model sistem?",
|
||||
"modelProvider.systemReasoningModel.key": "Model Penalaran Sistem",
|
||||
"modelProvider.systemReasoningModel.tip": "Atur model inferensi default yang akan digunakan untuk membuat aplikasi, serta fitur seperti pembuatan nama dialog dan saran pertanyaan berikutnya juga akan menggunakan model inferensi default.",
|
||||
"modelProvider.toBeConfigured": "Untuk dikonfigurasi",
|
||||
|
||||
@@ -409,12 +409,10 @@
|
||||
"modelProvider.selector.tip": "Questo modello è stato rimosso. Per favore aggiungi un modello o seleziona un altro modello.",
|
||||
"modelProvider.setupModelFirst": "Per favore, configura prima il tuo modello",
|
||||
"modelProvider.showModels": "Mostra Modelli",
|
||||
"modelProvider.showModelsNum": "Mostra {{num}} Modelli",
|
||||
"modelProvider.showMoreModelProvider": "Mostra più fornitori di modelli",
|
||||
"modelProvider.speechToTextModel.key": "Modello da Voce a Testo",
|
||||
"modelProvider.speechToTextModel.tip": "Imposta il modello predefinito per l'input da voce a testo nella conversazione.",
|
||||
"modelProvider.systemModelSettings": "Impostazioni Modello di Sistema",
|
||||
"modelProvider.systemModelSettingsLink": "Perché è necessario configurare un modello di sistema?",
|
||||
"modelProvider.systemReasoningModel.key": "Modello di Ragionamento di Sistema",
|
||||
"modelProvider.systemReasoningModel.tip": "Imposta il modello di inferenza predefinito da utilizzare per creare applicazioni, così come funzionalità come la generazione del nome del dialogo e il suggerimento della domanda successiva utilizzeranno anche il modello di inferenza predefinito.",
|
||||
"modelProvider.toBeConfigured": "Da configurare",
|
||||
|
||||
@@ -353,6 +353,7 @@
|
||||
"modelProvider.card.removeKey": "API キーを削除",
|
||||
"modelProvider.card.tip": "メッセージ枠は{{modelNames}}のモデルを使用することをサポートしています。無料枠は有料枠が使い果たされた後に消費されます。",
|
||||
"modelProvider.card.tokens": "トークン",
|
||||
"modelProvider.card.usagePriorityTip": "モデル実行時に優先して使用するリソースを設定します。",
|
||||
"modelProvider.collapse": "折り畳み",
|
||||
"modelProvider.config": "設定",
|
||||
"modelProvider.configLoadBalancing": "負荷分散の設定",
|
||||
@@ -409,12 +410,10 @@
|
||||
"modelProvider.selector.tip": "このモデルは削除されました。別のモデルを追加するか、別のモデルを選択してください。",
|
||||
"modelProvider.setupModelFirst": "まずモデルをセットアップしてください",
|
||||
"modelProvider.showModels": "モデルの表示",
|
||||
"modelProvider.showModelsNum": "{{num}}のモデルを表示",
|
||||
"modelProvider.showMoreModelProvider": "より多くのモデルプロバイダを表示",
|
||||
"modelProvider.speechToTextModel.key": "音声-to-テキストモデル",
|
||||
"modelProvider.speechToTextModel.tip": "会話での音声-to-テキスト入力に使用するデフォルトモデルを設定します。",
|
||||
"modelProvider.systemModelSettings": "システムモデル設定",
|
||||
"modelProvider.systemModelSettingsLink": "システムモデルの設定が必要な理由は何ですか?",
|
||||
"modelProvider.systemReasoningModel.key": "システム推論モデル",
|
||||
"modelProvider.systemReasoningModel.tip": "アプリの作成に使用されるデフォルトの推論モデルを設定します。また、対話名の生成や次の質問の提案などの機能もデフォルトの推論モデルを使用します。",
|
||||
"modelProvider.toBeConfigured": "設定中",
|
||||
|
||||
@@ -409,12 +409,10 @@
|
||||
"modelProvider.selector.tip": "이 모델은 삭제되었습니다. 다른 모델을 추가하거나 다른 모델을 선택하세요.",
|
||||
"modelProvider.setupModelFirst": "먼저 모델을 설정하세요",
|
||||
"modelProvider.showModels": "모델 표시",
|
||||
"modelProvider.showModelsNum": "{{num}}개의 모델 표시",
|
||||
"modelProvider.showMoreModelProvider": "더 많은 모델 제공자 표시",
|
||||
"modelProvider.speechToTextModel.key": "음성-to-텍스트 모델",
|
||||
"modelProvider.speechToTextModel.tip": "대화에서의 음성-to-텍스트 입력에 사용되는 기본 모델을 설정합니다.",
|
||||
"modelProvider.systemModelSettings": "시스템 모델 설정",
|
||||
"modelProvider.systemModelSettingsLink": "시스템 모델 설정이 필요한 이유는 무엇입니까?",
|
||||
"modelProvider.systemReasoningModel.key": "시스템 추론 모델",
|
||||
"modelProvider.systemReasoningModel.tip": "앱 구축에 사용되는 기본 추론 모델을 설정합니다. 또한 대화 이름 생성 및 다음 질문 제안과 같은 기능도 기본 추론 모델을 사용합니다.",
|
||||
"modelProvider.toBeConfigured": "구성 예정",
|
||||
|
||||
@@ -409,12 +409,10 @@
|
||||
"modelProvider.selector.tip": "This model has been removed. Please add a model or select another model.",
|
||||
"modelProvider.setupModelFirst": "Please set up your model first",
|
||||
"modelProvider.showModels": "Show Models",
|
||||
"modelProvider.showModelsNum": "Show {{num}} Models",
|
||||
"modelProvider.showMoreModelProvider": "Show more model provider",
|
||||
"modelProvider.speechToTextModel.key": "Speech-to-Text Model",
|
||||
"modelProvider.speechToTextModel.tip": "Set the default model for speech-to-text input in conversation.",
|
||||
"modelProvider.systemModelSettings": "System Model Settings",
|
||||
"modelProvider.systemModelSettingsLink": "Why is it necessary to set up a system model?",
|
||||
"modelProvider.systemReasoningModel.key": "System Reasoning Model",
|
||||
"modelProvider.systemReasoningModel.tip": "Set the default inference model to be used for creating applications, as well as features such as dialogue name generation and next question suggestion will also use the default inference model.",
|
||||
"modelProvider.toBeConfigured": "To be configured",
|
||||
|
||||
@@ -409,12 +409,10 @@
|
||||
"modelProvider.selector.tip": "Ten model został usunięty. Proszę dodać model lub wybrać inny model.",
|
||||
"modelProvider.setupModelFirst": "Proszę najpierw skonfigurować swój model",
|
||||
"modelProvider.showModels": "Pokaż modele",
|
||||
"modelProvider.showModelsNum": "Pokaż {{num}} modele",
|
||||
"modelProvider.showMoreModelProvider": "Pokaż więcej dostawców modeli",
|
||||
"modelProvider.speechToTextModel.key": "Model mowy na tekst",
|
||||
"modelProvider.speechToTextModel.tip": "Ustaw domyślny model do przetwarzania mowy na tekst w rozmowach.",
|
||||
"modelProvider.systemModelSettings": "Ustawienia modelu systemowego",
|
||||
"modelProvider.systemModelSettingsLink": "Dlaczego konieczne jest skonfigurowanie modelu systemowego?",
|
||||
"modelProvider.systemReasoningModel.key": "Model wnioskowania systemowego",
|
||||
"modelProvider.systemReasoningModel.tip": "Ustaw domyślny model wnioskowania do użytku przy tworzeniu aplikacji, a także cechy takie jak generowanie nazw dialogów i sugestie następnego pytania będą również korzystać z domyślnego modelu wnioskowania.",
|
||||
"modelProvider.toBeConfigured": "Do skonfigurowania",
|
||||
|
||||
@@ -409,12 +409,10 @@
|
||||
"modelProvider.selector.tip": "Este modelo foi removido. Adicione um modelo ou selecione outro modelo.",
|
||||
"modelProvider.setupModelFirst": "Por favor, configure seu modelo primeiro",
|
||||
"modelProvider.showModels": "Mostrar Modelos",
|
||||
"modelProvider.showModelsNum": "Mostrar {{num}} Modelos",
|
||||
"modelProvider.showMoreModelProvider": "Mostrar mais provedor de modelo",
|
||||
"modelProvider.speechToTextModel.key": "Modelo de Fala para Texto",
|
||||
"modelProvider.speechToTextModel.tip": "Defina o modelo padrão para entrada de fala para texto na conversa.",
|
||||
"modelProvider.systemModelSettings": "Configurações do Modelo do Sistema",
|
||||
"modelProvider.systemModelSettingsLink": "Por que é necessário configurar um modelo do sistema?",
|
||||
"modelProvider.systemReasoningModel.key": "Modelo de Raciocínio do Sistema",
|
||||
"modelProvider.systemReasoningModel.tip": "Defina o modelo de inferência padrão a ser usado para criar aplicativos, bem como recursos como geração de nomes de diálogo e sugestão de próxima pergunta também usarão o modelo de inferência padrão.",
|
||||
"modelProvider.toBeConfigured": "A ser configurado",
|
||||
|
||||
@@ -409,12 +409,10 @@
|
||||
"modelProvider.selector.tip": "Acest model a fost eliminat. Vă rugăm să adăugați un model sau să selectați un alt model.",
|
||||
"modelProvider.setupModelFirst": "Vă rugăm să configurați mai întâi modelul",
|
||||
"modelProvider.showModels": "Arată modele",
|
||||
"modelProvider.showModelsNum": "Arată {{num}} modele",
|
||||
"modelProvider.showMoreModelProvider": "Arată mai multe furnizori de modele",
|
||||
"modelProvider.speechToTextModel.key": "Model de conversie text-la-vorbire",
|
||||
"modelProvider.speechToTextModel.tip": "Setați modelul implicit pentru intrarea de conversie text-la-vorbire în conversație.",
|
||||
"modelProvider.systemModelSettings": "Setări model de sistem",
|
||||
"modelProvider.systemModelSettingsLink": "De ce este necesar să se configureze un model de sistem?",
|
||||
"modelProvider.systemReasoningModel.key": "Model de raționament de sistem",
|
||||
"modelProvider.systemReasoningModel.tip": "Setați modelul de inferență implicit care va fi utilizat pentru crearea aplicațiilor, precum și caracteristici precum generarea de nume pentru dialog și sugestia următoarei întrebări vor utiliza, de asemenea, modelul de inferență implicit.",
|
||||
"modelProvider.toBeConfigured": "De configurat",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user