Merge branch 'feat/model-provider-refactor' into deploy/dev

This commit is contained in:
yyh
2026-03-04 23:39:41 +08:00
99 changed files with 4920 additions and 2224 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { DialogDescription, DialogTitle } from '@/app/components/base/ui/dialog'
import Button from '../../base/button'
import DifyLogo from '../../base/logo/dify-logo'
@ -20,19 +20,19 @@ const Header = ({
<div className="py-[5px]">
<DifyLogo className="h-[27px] w-[60px]" />
</div>
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,6 +23,7 @@ import {
useAnthropicBuyQuota,
useCurrentProviderAndModel,
useDefaultModel,
useInvalidateDefaultModel,
useLanguage,
useMarketplaceAllPlugins,
useModelList,
@ -864,6 +865,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', {

View File

@ -25,6 +25,7 @@ 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,
@ -323,6 +324,7 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText:
export const useRefreshModel = () => {
const { eventEmitter } = useEventEmitterContextContext()
const queryClient = useQueryClient()
const updateModelProviders = useUpdateModelProviders()
const updateModelList = useUpdateModelList()
const handleRefreshModel = useCallback((
@ -330,6 +332,11 @@ export const useRefreshModel = () => {
CustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
refreshModelList?: boolean,
) => {
queryClient.invalidateQueries({
queryKey: consoleQuery.modelProviders.models.key(),
refetchType: 'none',
})
updateModelProviders()
provider.supported_model_types.forEach((type) => {
@ -345,7 +352,7 @@ export const useRefreshModel = () => {
if (CustomConfigurationModelFixedFields?.__model_type)
updateModelList(CustomConfigurationModelFixedFields.__model_type)
}
}, [eventEmitter, updateModelList, updateModelProviders])
}, [eventEmitter, queryClient, updateModelList, updateModelProviders])
return {
handleRefreshModel,

View File

@ -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 = [
@ -88,11 +83,15 @@ 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
mockEnableMarketplace = true
Object.keys(mockDefaultModels).forEach((key) => {
mockDefaultModels[key] = { data: null, isLoading: false }
})
@ -153,7 +152,7 @@ describe('ModelProviderPage', () => {
})
it('should hide marketplace section when marketplace feature is disabled', () => {
mockGlobalState.systemFeatures.enable_marketplace = false
mockEnableMarketplace = false
render(<ModelProviderPage searchText="" />)
@ -174,7 +173,8 @@ describe('ModelProviderPage', () => {
})
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()
expect(screen.getByText('common.modelProvider.emptyProviderTitle')).toBeInTheDocument()
})

View File

@ -1,13 +1,13 @@
import type {
ModelProvider,
} from './declarations'
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,
@ -20,6 +20,7 @@ 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'
@ -32,14 +33,30 @@ 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
@ -109,10 +126,6 @@ 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')}>
@ -123,7 +136,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
)}
>
{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 && warningTextKey && (
{showWarning && (
<div className="flex items-center gap-1 text-text-primary system-xs-medium">
<span className="i-ri-alert-fill h-4 w-4 text-text-warning-secondary" />
<span className="max-w-[460px] truncate" title={t(warningTextKey, { ns: 'common' })}>{t(warningTextKey, { ns: 'common' })}</span>
@ -140,7 +153,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
/>
</div>
</div>
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} isLoading={isValidatingCurrentWorkspace} />}
<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">
@ -156,6 +169,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
<ProviderAddedCard
key={provider.provider}
provider={provider}
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
/>
))}
</div>
@ -169,13 +183,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}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import type { ModelProvider } from '../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { changeModelProviderPriority } from '@/service/common'
import { ConfigurationMethodEnum } from '../declarations'
@ -71,6 +72,21 @@ vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div data-testid="indicator">{color}</div>,
}))
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
},
})
const renderWithQueryClient = (provider: ModelProvider) => {
const queryClient = createTestQueryClient()
return render(
<QueryClientProvider client={queryClient}>
<CredentialPanel provider={provider} />
</QueryClientProvider>,
)
}
describe('CredentialPanel', () => {
const mockProvider: ModelProvider = {
provider: 'test-provider',
@ -94,7 +110,7 @@ describe('CredentialPanel', () => {
})
it('should show credential name and configuration actions', () => {
render(<CredentialPanel provider={mockProvider} />)
renderWithQueryClient(mockProvider)
expect(screen.getByText('test-credential')).toBeInTheDocument()
expect(screen.getByTestId('config-provider')).toBeInTheDocument()
@ -103,7 +119,7 @@ describe('CredentialPanel', () => {
it('should show unauthorized status label when credential is missing', () => {
mockCredentialStatus.hasCredential = false
render(<CredentialPanel provider={mockProvider} />)
renderWithQueryClient(mockProvider)
expect(screen.getByText(/modelProvider\.auth\.unAuthorized/)).toBeInTheDocument()
})
@ -111,7 +127,7 @@ describe('CredentialPanel', () => {
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} />)
renderWithQueryClient({ ...mockProvider, preferred_provider_type: 'custom' } as ModelProvider)
expect(screen.getByText(/modelProvider\.auth\.authRemoved/)).toBeInTheDocument()
expect(screen.getByTestId('priority-use-tip')).toBeInTheDocument()
@ -120,7 +136,7 @@ describe('CredentialPanel', () => {
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} />)
renderWithQueryClient(mockProvider)
fireEvent.click(screen.getByTestId('priority-selector'))
@ -138,7 +154,7 @@ describe('CredentialPanel', () => {
...mockProvider,
provider_credential_schema: null,
} as unknown as ModelProvider
render(<CredentialPanel provider={providerNoSchema} />)
renderWithQueryClient(providerNoSchema)
expect(screen.getByTestId('priority-selector')).toBeInTheDocument()
expect(screen.queryByTestId('config-provider')).not.toBeInTheDocument()
})

View File

@ -1,7 +1,7 @@
import type {
ModelProvider,
} from '../declarations'
import { useMemo } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast'
import { ConfigProvider } from '@/app/components/header/account-setting/model-provider-page/model-auth'
@ -9,6 +9,7 @@ import { useCredentialStatus } from '@/app/components/header/account-setting/mod
import Indicator from '@/app/components/header/indicator'
import { IS_CLOUD_EDITION } from '@/config'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { consoleQuery } from '@/service/client'
import { changeModelProviderPriority } from '@/service/common'
import { cn } from '@/utils/classnames'
import {
@ -23,6 +24,8 @@ import {
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './index'
import PrioritySelector from './priority-selector'
import PriorityUseTip from './priority-use-tip'
import SystemQuotaCard from './system-quota-card'
import { useTrialCredits } from './use-trial-credits'
type CredentialPanelProps = {
provider: ModelProvider
@ -34,6 +37,7 @@ const CredentialPanel = ({
const { t } = useTranslation()
const { notify } = useToastContext()
const { eventEmitter } = useEventEmitterContextContext()
const queryClient = useQueryClient()
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const customConfig = provider.custom_configuration
@ -50,6 +54,8 @@ const CredentialPanel = ({
} = useCredentialStatus(provider)
const showPrioritySelector = systemConfig.enabled && isCustomConfigured && IS_CLOUD_EDITION
const isUsingSystemQuota = systemConfig.enabled && priorityUseType === PreferredProviderTypeEnum.system && IS_CLOUD_EDITION
const { isExhausted } = useTrialCredits()
const handleChangePriority = async (key: PreferredProviderTypeEnum) => {
const res = await changeModelProviderPriority({
@ -60,6 +66,10 @@ const CredentialPanel = ({
})
if (res.result === 'success') {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
queryClient.invalidateQueries({
queryKey: consoleQuery.modelProviders.models.key(),
refetchType: 'none',
})
updateModelProviders()
configurateMethods.forEach((method) => {
@ -73,24 +83,40 @@ const CredentialPanel = ({
} as any)
}
}
const credentialLabel = useMemo(() => {
if (!hasCredential)
return t('modelProvider.auth.unAuthorized', { ns: 'common' })
if (authorized)
return current_credential_name
if (authRemoved)
return t('modelProvider.auth.authRemoved', { ns: 'common' })
const credentialLabel = !hasCredential
? t('modelProvider.auth.unAuthorized', { ns: 'common' })
: authorized
? current_credential_name
: authRemoved
? t('modelProvider.auth.authRemoved', { ns: 'common' })
: ''
return ''
}, [authorized, authRemoved, current_credential_name, hasCredential])
const color = (authRemoved || !hasCredential)
? 'red'
: notAllowedToUse
? 'gray'
: 'green'
const color = useMemo(() => {
if (authRemoved || !hasCredential)
return 'red'
if (notAllowedToUse)
return 'gray'
return 'green'
}, [authRemoved, notAllowedToUse, hasCredential])
if (isUsingSystemQuota) {
return (
<SystemQuotaCard variant={isExhausted ? 'destructive' : 'default'}>
<SystemQuotaCard.Label>
{isExhausted
? t('modelProvider.card.quotaExhausted', { ns: 'common' })
: t('modelProvider.card.aiCreditsInUse', { ns: 'common' })}
</SystemQuotaCard.Label>
<SystemQuotaCard.Actions>
<ConfigProvider provider={provider} />
{showPrioritySelector && (
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
)}
</SystemQuotaCard.Actions>
</SystemQuotaCard>
)
}
return (
<>
@ -101,7 +127,7 @@ const CredentialPanel = ({
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="mb-1 flex h-5 items-center justify-between pl-2 pr-[7px] pt-1 text-text-tertiary system-xs-medium">
<div
className={cn(
'grow truncate',

View File

@ -1,17 +1,30 @@
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 { ConfigurationMethodEnum } from '../declarations'
import ProviderAddedCard from './index'
let mockIsCurrentWorkspaceManager = true
const mockFetchModelProviderModels = vi.fn()
const mockQueryOptions = vi.fn(({ input, ...options }: { input: { params: { provider: string } }, enabled?: boolean }) => ({
queryKey: ['console', 'modelProviders', 'models', input.params.provider],
queryFn: () => mockFetchModelProviderModels(input.params.provider),
...options,
}))
const mockEventEmitter = {
useSubscription: vi.fn(),
emit: vi.fn(),
}
vi.mock('@/service/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', () => ({
@ -53,6 +66,21 @@ 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()
return render(
<QueryClientProvider client={queryClient}>
{node}
</QueryClientProvider>,
)
}
describe('ProviderAddedCard', () => {
const mockProvider = {
provider: 'langgenius/openai/openai',
@ -67,19 +95,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 +120,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 +135,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: [] })
@ -130,7 +162,7 @@ describe('ProviderAddedCard', () => {
...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()
})
@ -139,9 +171,9 @@ describe('ProviderAddedCard', () => {
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[] })
mockFetchModelProviderModels.mockResolvedValue({ data: [] })
render(<ProviderAddedCard provider={mockProvider} />)
renderWithQueryClient(<ProviderAddedCard provider={mockProvider} />)
expect(capturedHandler).toBeDefined()
act(() => {
@ -152,7 +184,7 @@ describe('ProviderAddedCard', () => {
})
await waitFor(() => {
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(1)
})
// Should ignore non-matching events
@ -160,7 +192,7 @@ describe('ProviderAddedCard', () => {
capturedHandler({ type: 'OTHER', payload: '' })
capturedHandler(null)
})
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(1)
})
it('should render custom model actions for workspace managers', () => {
@ -168,13 +200,22 @@ describe('ProviderAddedCard', () => {
...mockProvider,
configurate_methods: [ConfigurationMethodEnum.customizableModel],
} as unknown as ModelProvider
const { rerender } = render(<ProviderAddedCard provider={customConfigProvider} />)
const queryClient = createTestQueryClient()
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<ProviderAddedCard provider={customConfigProvider} />
</QueryClientProvider>,
)
expect(screen.getByTestId('manage-custom-model')).toBeInTheDocument()
expect(screen.getByTestId('add-custom-model')).toBeInTheDocument()
mockIsCurrentWorkspaceManager = false
rerender(<ProviderAddedCard provider={customConfigProvider} />)
rerender(
<QueryClientProvider client={queryClient}>
<ProviderAddedCard provider={customConfigProvider} />
</QueryClientProvider>,
)
expect(screen.queryByTestId('manage-custom-model')).not.toBeInTheDocument()
})
})

View File

@ -1,11 +1,13 @@
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 type { EventEmitterValue } from '@/context/event-emitter'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
AddCustomModel,
@ -14,7 +16,8 @@ import {
import { IS_CE_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { fetchModelProviderModelList } from '@/service/common'
import { useProviderContext } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import { cn } from '@/utils/classnames'
import { ConfigurationMethodEnum } from '../declarations'
import ModelBadge from '../model-badge'
@ -25,92 +28,127 @@ 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'
const isModelProviderCustomModelListUpdateEvent = (
value: EventEmitterValue,
providerName: string,
): value is {
type: typeof UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST
payload: string
} => {
return typeof value === 'object'
&& value !== null
&& value.type === UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST
&& typeof value.payload === 'string'
&& value.payload === providerName
}
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 { refreshModelProviders } = useProviderContext()
const [collapsed, setCollapsed] = useState(true)
const [modelList, setModelList] = useState<ModelItem[]>([])
const configurationMethods = provider.configurate_methods.filter(method => method !== ConfigurationMethodEnum.fetchFromRemote)
const currentProviderName = provider.provider
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: !collapsed,
refetchOnWindowFocus: false,
select: response => response.data,
}))
const hasModelList = hasFetchedModelList && !!modelList.length
const showCollapsedSection = collapsed || !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 (collapsed)
setCollapsed(false)
refetchModelList().catch(() => {})
}, [collapsed, currentProviderName, refetchModelList])
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) {
if (collapsed) {
setCollapsed(false)
return
}
getModelList(provider.provider)
}
refetchModelList().catch(() => {})
}, [collapsed, loading, refetchModelList])
eventEmitter?.useSubscription((v: any) => {
if (v?.type === UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST && v.payload === provider.provider)
getModelList(v.payload)
})
const handleModelProviderCustomModelListUpdate = useCallback((value: EventEmitterValue) => {
if (!isModelProviderCustomModelListUpdateEvent(value, currentProviderName))
return
refreshModelList(currentProviderName)
}, [currentProviderName, refreshModelList])
eventEmitter?.useSubscription(handleModelProviderCustomModelListUpdate)
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) && (
<>
@ -148,7 +186,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
</div>
)}
{
configurationMethods.includes(ConfigurationMethodEnum.customizableModel) && isCurrentWorkspaceManager && (
showCustomModelActions && (
<div className="flex grow justify-end">
<ManageCustomModelCredentials
provider={provider}
@ -166,12 +204,12 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
)
}
{
!collapsed && (
!showCollapsedSection && (
<ModelList
provider={provider}
models={modelList}
onCollapse={() => setCollapsed(true)}
onChange={(provider: string) => getModelList(provider)}
onChange={refreshModelList}
/>
)
}

View File

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

View File

@ -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,6 +32,7 @@ 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 toggleModelEnablingStatus = useCallback(async (enabled: boolean) => {
@ -37,9 +40,14 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad
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: consoleQuery.modelProviders.models.key(),
refetchType: 'none',
})
updateModelList(model.model_type)
onChange?.(provider.provider)
}, [model.model, model.model_type, onChange, provider.provider, updateModelList])
}, [model.model, model.model_type, onChange, provider.provider, queryClient, updateModelList])
const { run: debouncedToggleModelEnablingStatus } = useDebounceFn(toggleModelEnablingStatus, { wait: 500 })

View File

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

View File

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

View File

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

View File

@ -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 && (

View File

@ -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 }: { children: ReactNode }) => {
const variant = useContext(VariantContext)
return (
<div className={cn(
'relative z-[1] truncate px-1.5 pt-1 system-xs-medium',
labelVariants[variant],
)}
>
{children}
</div>
)
}
const Actions = ({ children }: { children: ReactNode }) => {
return (
<div className="relative z-[1] flex items-center gap-0.5">
{children}
</div>
)
}
SystemQuotaCard.Label = Label
SystemQuotaCard.Actions = Actions
export default SystemQuotaCard

View File

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

View File

@ -21,7 +21,7 @@ const ProviderIcon: FC<ProviderIconProps> = ({
if (provider.provider === 'langgenius/anthropic/anthropic') {
return (
<div className="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>
)

View File

@ -18,6 +18,7 @@ vi.mock('react-i18next', async () => {
'modelProvider.speechToTextModel.tip': 'Speech to text model tip',
'modelProvider.ttsModel.key': 'TTS Model',
'modelProvider.ttsModel.tip': 'TTS model tip',
'modelProvider.systemModelSettingsLink': 'Description text here',
'operation.cancel': 'Cancel',
'operation.save': 'Save',
'actionMsg.modifiedSuccessfully': 'Modified successfully',
@ -101,7 +102,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)
@ -115,7 +116,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(() => {

View File

@ -3,17 +3,22 @@ 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'
import Tooltip from '@/app/components/base/tooltip'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
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'
@ -35,6 +40,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,
@ -114,139 +134,121 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
}
}
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>
<DialogDescription className="mt-1 text-text-tertiary system-xs-regular">
{t('modelProvider.systemModelSettingsLink', { ns: 'common' })}
</DialogDescription>
</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}
@ -254,9 +256,9 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -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 = {

View File

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

View File

@ -1,3 +1,5 @@
import type { ReactElement, ReactNode } from 'react'
import { cloneElement } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginSource } from '../../types'
@ -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()
})
})

View File

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

View File

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

View File

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

View File

@ -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,
])

View File

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

View File

@ -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(),
}}
/>,

View File

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

View File

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

View File

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

View File

@ -1,43 +0,0 @@
'use client'
import { SerwistProvider } from '@serwist/turbopack/react'
import { useEffect } from 'react'
import { IS_DEV } from '@/config'
import { env } from '@/env'
import { isClient } from '@/utils/client'
export function PWAProvider({ children }: { children: React.ReactNode }) {
if (IS_DEV) {
return <DisabledPWAProvider>{children}</DisabledPWAProvider>
}
const basePath = env.NEXT_PUBLIC_BASE_PATH
const swUrl = `${basePath}/serwist/sw.js`
return (
<SerwistProvider swUrl={swUrl}>
{children}
</SerwistProvider>
)
}
function DisabledPWAProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (isClient && 'serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations()
.then((registrations) => {
registrations.forEach((registration) => {
registration.unregister()
.catch((error) => {
console.error('Error unregistering service worker:', error)
})
})
})
.catch((error) => {
console.error('Error unregistering service workers:', error)
})
}
}, [])
return <>{children}</>
}

View File

@ -1,4 +1,5 @@
import type { CommonEdgeType, CommonNodeType, Edge, Node } from '../types'
import type { CommonEdgeType, CommonNodeType, Edge, Node, ToolWithProvider, WorkflowRunningData } from '../types'
import type { NodeTracing } from '@/types/workflow'
import { Position } from 'reactflow'
import { CUSTOM_NODE } from '../constants'
import { BlockEnum, NodeRunningStatus } from '../types'
@ -108,4 +109,74 @@ export function createLinearGraph(nodeCount: number): { nodes: Node[], edges: Ed
return { nodes, edges }
}
// ---------------------------------------------------------------------------
// Workflow-level factories
// ---------------------------------------------------------------------------
export function createWorkflowRunningData(
overrides?: Partial<WorkflowRunningData>,
): WorkflowRunningData {
return {
task_id: 'task-test',
result: {
status: 'running',
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
...overrides?.result,
},
tracing: overrides?.tracing ?? [],
...overrides,
}
}
export function createNodeTracing(
overrides?: Partial<NodeTracing>,
): NodeTracing {
const nodeId = overrides?.node_id ?? 'node-1'
return {
id: `trace-${nodeId}`,
index: 0,
predecessor_node_id: '',
node_id: nodeId,
node_type: BlockEnum.Code,
title: 'Node',
inputs: null,
inputs_truncated: false,
process_data: null,
process_data_truncated: false,
outputs_truncated: false,
status: NodeRunningStatus.Running,
elapsed_time: 0,
metadata: { iterator_length: 0, iterator_index: 0, loop_length: 0, loop_index: 0 },
created_at: 0,
created_by: { id: 'user-1', name: 'Test', email: 'test@test.com' },
finished_at: 0,
...overrides,
}
}
export function createToolWithProvider(
overrides?: Partial<ToolWithProvider>,
): ToolWithProvider {
return {
id: 'tool-provider-1',
name: 'test-tool',
author: 'test',
description: { en_US: 'Test tool', zh_Hans: '测试工具' },
icon: '/icon.svg',
icon_dark: '/icon-dark.svg',
label: { en_US: 'Test Tool', zh_Hans: '测试工具' },
type: 'builtin',
team_credentials: {},
is_team_authorization: false,
allow_delete: true,
labels: [],
tools: [],
meta: { version: '0.0.1' },
plugin_id: 'plugin-1',
...overrides,
}
}
export { BlockEnum, NodeRunningStatus }

View File

@ -1,59 +0,0 @@
import { noop } from 'es-toolkit'
/**
* Default hooks store state.
* All function fields default to noop / vi.fn() stubs.
* Use `createHooksStoreState(overrides)` to get a customised state object.
*/
export function createHooksStoreState(overrides: Record<string, unknown> = {}) {
return {
refreshAll: noop,
// draft sync
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
syncWorkflowDraftWhenPageClose: noop,
handleRefreshWorkflowDraft: noop,
handleBackupDraft: noop,
handleLoadBackupDraft: noop,
handleRestoreFromPublishedWorkflow: noop,
// run
handleRun: noop,
handleStopRun: noop,
handleStartWorkflowRun: noop,
handleWorkflowStartRunInWorkflow: noop,
handleWorkflowStartRunInChatflow: noop,
handleWorkflowTriggerScheduleRunInWorkflow: noop,
handleWorkflowTriggerWebhookRunInWorkflow: noop,
handleWorkflowTriggerPluginRunInWorkflow: noop,
handleWorkflowRunAllTriggersInWorkflow: noop,
// meta
availableNodesMetaData: undefined,
configsMap: undefined,
// export / DSL
exportCheck: vi.fn().mockResolvedValue(undefined),
handleExportDSL: vi.fn().mockResolvedValue(undefined),
getWorkflowRunAndTraceUrl: vi.fn().mockReturnValue({ runUrl: '', traceUrl: '' }),
// inspect vars
fetchInspectVars: vi.fn().mockResolvedValue(undefined),
hasNodeInspectVars: vi.fn().mockReturnValue(false),
hasSetInspectVar: vi.fn().mockReturnValue(false),
fetchInspectVarValue: vi.fn().mockResolvedValue(undefined),
editInspectVarValue: vi.fn().mockResolvedValue(undefined),
renameInspectVarName: vi.fn().mockResolvedValue(undefined),
appendNodeInspectVars: noop,
deleteInspectVar: vi.fn().mockResolvedValue(undefined),
deleteNodeInspectorVars: vi.fn().mockResolvedValue(undefined),
deleteAllInspectorVars: vi.fn().mockResolvedValue(undefined),
isInspectVarEdited: vi.fn().mockReturnValue(false),
resetToLastRunVar: vi.fn().mockResolvedValue(undefined),
invalidateSysVarValues: noop,
resetConversationVar: vi.fn().mockResolvedValue(undefined),
invalidateConversationVarValues: noop,
...overrides,
}
}

View File

@ -1,110 +0,0 @@
/**
* ReactFlow mock factory for workflow tests.
*
* Usage — add this to the top of any test file that imports reactflow:
*
* vi.mock('reactflow', async () => (await import('../__tests__/mock-reactflow')).createReactFlowMock())
*
* Or for more control:
*
* vi.mock('reactflow', async () => {
* const base = (await import('../__tests__/mock-reactflow')).createReactFlowMock()
* return { ...base, useReactFlow: () => ({ ...base.useReactFlow(), fitView: vi.fn() }) }
* })
*/
import * as React from 'react'
export function createReactFlowMock(overrides: Record<string, unknown> = {}) {
const noopComponent: React.FC<{ children?: React.ReactNode }> = ({ children }) =>
React.createElement('div', { 'data-testid': 'reactflow-mock' }, children)
noopComponent.displayName = 'ReactFlowMock'
const backgroundComponent: React.FC = () => null
backgroundComponent.displayName = 'BackgroundMock'
return {
// re-export the real Position enum
Position: { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' },
MarkerType: { Arrow: 'arrow', ArrowClosed: 'arrowclosed' },
ConnectionMode: { Strict: 'strict', Loose: 'loose' },
ConnectionLineType: { Bezier: 'default', Straight: 'straight', Step: 'step', SmoothStep: 'smoothstep' },
// components
default: noopComponent,
ReactFlow: noopComponent,
ReactFlowProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
Background: backgroundComponent,
MiniMap: backgroundComponent,
Controls: backgroundComponent,
Handle: (props: Record<string, unknown>) => React.createElement('div', { 'data-testid': 'handle', ...props }),
BaseEdge: (props: Record<string, unknown>) => React.createElement('path', props),
EdgeLabelRenderer: ({ children }: { children?: React.ReactNode }) =>
React.createElement('div', null, children),
// hooks
useReactFlow: () => ({
setCenter: vi.fn(),
fitView: vi.fn(),
zoomIn: vi.fn(),
zoomOut: vi.fn(),
zoomTo: vi.fn(),
getNodes: vi.fn().mockReturnValue([]),
getEdges: vi.fn().mockReturnValue([]),
getNode: vi.fn(),
setNodes: vi.fn(),
setEdges: vi.fn(),
addNodes: vi.fn(),
addEdges: vi.fn(),
deleteElements: vi.fn(),
getViewport: vi.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }),
setViewport: vi.fn(),
screenToFlowPosition: vi.fn().mockImplementation((pos: { x: number, y: number }) => pos),
flowToScreenPosition: vi.fn().mockImplementation((pos: { x: number, y: number }) => pos),
toObject: vi.fn().mockReturnValue({ nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }),
viewportInitialized: true,
}),
useStoreApi: () => ({
getState: vi.fn().mockReturnValue({
nodeInternals: new Map(),
edges: [],
transform: [0, 0, 1],
d3Selection: null,
d3Zoom: null,
}),
setState: vi.fn(),
subscribe: vi.fn().mockReturnValue(vi.fn()),
}),
useNodesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]),
useEdgesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]),
useStore: vi.fn().mockReturnValue(null),
useNodes: vi.fn().mockReturnValue([]),
useEdges: vi.fn().mockReturnValue([]),
useViewport: vi.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }),
useOnSelectionChange: vi.fn(),
useKeyPress: vi.fn().mockReturnValue(false),
useUpdateNodeInternals: vi.fn().mockReturnValue(vi.fn()),
useOnViewportChange: vi.fn(),
useNodeId: vi.fn().mockReturnValue(null),
// utils
getOutgoers: vi.fn().mockReturnValue([]),
getIncomers: vi.fn().mockReturnValue([]),
getConnectedEdges: vi.fn().mockReturnValue([]),
isNode: vi.fn().mockReturnValue(true),
isEdge: vi.fn().mockReturnValue(false),
addEdge: vi.fn().mockImplementation((_edge: unknown, edges: unknown[]) => edges),
applyNodeChanges: vi.fn().mockImplementation((_changes: unknown[], nodes: unknown[]) => nodes),
applyEdgeChanges: vi.fn().mockImplementation((_changes: unknown[], edges: unknown[]) => edges),
getBezierPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
getSmoothStepPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
getStraightPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
internalsSymbol: Symbol('internals'),
...overrides,
}
}

View File

@ -1,199 +0,0 @@
import type { ControlMode, Node } from '../types'
import { noop } from 'es-toolkit'
import { DEFAULT_ITER_TIMES, DEFAULT_LOOP_TIMES } from '../constants'
/**
* Default workflow store state covering all slices.
* Use `createWorkflowStoreState(overrides)` to get a state object
* that can be injected via `useWorkflowStore.setState(...)` or
* used as the return value of a mocked `useStore` selector.
*/
export function createWorkflowStoreState(overrides: Record<string, unknown> = {}) {
return {
// --- workflow-slice ---
workflowRunningData: undefined,
isListening: false,
listeningTriggerType: null,
listeningTriggerNodeId: null,
listeningTriggerNodeIds: [],
listeningTriggerIsAll: false,
clipboardElements: [] as Node[],
selection: null,
bundleNodeSize: null,
controlMode: 'pointer' as ControlMode,
mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 },
showConfirm: undefined,
controlPromptEditorRerenderKey: 0,
showImportDSLModal: false,
fileUploadConfig: undefined,
// --- node-slice ---
showSingleRunPanel: false,
nodeAnimation: false,
candidateNode: undefined,
nodeMenu: undefined,
showAssignVariablePopup: undefined,
hoveringAssignVariableGroupId: undefined,
connectingNodePayload: undefined,
enteringNodePayload: undefined,
iterTimes: DEFAULT_ITER_TIMES,
loopTimes: DEFAULT_LOOP_TIMES,
iterParallelLogMap: new Map(),
pendingSingleRun: undefined,
// --- panel-slice ---
panelWidth: 420,
showFeaturesPanel: false,
showWorkflowVersionHistoryPanel: false,
showInputsPanel: false,
showDebugAndPreviewPanel: false,
panelMenu: undefined,
selectionMenu: undefined,
showVariableInspectPanel: false,
initShowLastRunTab: false,
// --- help-line-slice ---
helpLineHorizontal: undefined,
helpLineVertical: undefined,
// --- history-slice ---
historyWorkflowData: undefined,
showRunHistory: false,
versionHistory: [],
// --- chat-variable-slice ---
showChatVariablePanel: false,
showGlobalVariablePanel: false,
conversationVariables: [],
// --- env-variable-slice ---
showEnvPanel: false,
environmentVariables: [],
envSecrets: {},
// --- form-slice ---
inputs: {},
files: [],
// --- tool-slice ---
toolPublished: false,
lastPublishedHasUserInput: false,
buildInTools: undefined,
customTools: undefined,
workflowTools: undefined,
mcpTools: undefined,
// --- version-slice ---
draftUpdatedAt: 0,
publishedAt: 0,
currentVersion: null,
isRestoring: false,
// --- workflow-draft-slice ---
backupDraft: undefined,
syncWorkflowDraftHash: '',
isSyncingWorkflowDraft: false,
isWorkflowDataLoaded: false,
nodes: [] as Node[],
// --- inspect-vars-slice ---
currentFocusNodeId: null,
nodesWithInspectVars: [],
conversationVars: [],
// --- layout-slice ---
workflowCanvasWidth: undefined,
workflowCanvasHeight: undefined,
rightPanelWidth: undefined,
nodePanelWidth: 420,
previewPanelWidth: 420,
otherPanelWidth: 420,
bottomPanelWidth: 0,
bottomPanelHeight: 0,
variableInspectPanelHeight: 300,
maximizeCanvas: false,
// --- setters (all default to noop, override as needed) ---
setWorkflowRunningData: noop,
setIsListening: noop,
setListeningTriggerType: noop,
setListeningTriggerNodeId: noop,
setListeningTriggerNodeIds: noop,
setListeningTriggerIsAll: noop,
setClipboardElements: noop,
setSelection: noop,
setBundleNodeSize: noop,
setControlMode: noop,
setMousePosition: noop,
setShowConfirm: noop,
setControlPromptEditorRerenderKey: noop,
setShowImportDSLModal: noop,
setFileUploadConfig: noop,
setShowSingleRunPanel: noop,
setNodeAnimation: noop,
setCandidateNode: noop,
setNodeMenu: noop,
setShowAssignVariablePopup: noop,
setHoveringAssignVariableGroupId: noop,
setConnectingNodePayload: noop,
setEnteringNodePayload: noop,
setIterTimes: noop,
setLoopTimes: noop,
setIterParallelLogMap: noop,
setPendingSingleRun: noop,
setShowFeaturesPanel: noop,
setShowWorkflowVersionHistoryPanel: noop,
setShowInputsPanel: noop,
setShowDebugAndPreviewPanel: noop,
setPanelMenu: noop,
setSelectionMenu: noop,
setShowVariableInspectPanel: noop,
setInitShowLastRunTab: noop,
setHelpLineHorizontal: noop,
setHelpLineVertical: noop,
setHistoryWorkflowData: noop,
setShowRunHistory: noop,
setVersionHistory: noop,
setShowChatVariablePanel: noop,
setShowGlobalVariablePanel: noop,
setConversationVariables: noop,
setShowEnvPanel: noop,
setEnvironmentVariables: noop,
setEnvSecrets: noop,
setInputs: noop,
setFiles: noop,
setToolPublished: noop,
setLastPublishedHasUserInput: noop,
setDraftUpdatedAt: noop,
setPublishedAt: noop,
setCurrentVersion: noop,
setIsRestoring: noop,
setBackupDraft: noop,
setSyncWorkflowDraftHash: noop,
setIsSyncingWorkflowDraft: noop,
setIsWorkflowDataLoaded: noop,
setNodes: noop,
flushPendingSync: noop,
setCurrentFocusNodeId: noop,
setNodesWithInspectVars: noop,
setNodeInspectVars: noop,
deleteAllInspectVars: noop,
deleteNodeInspectVars: noop,
setInspectVarValue: noop,
resetToLastRunVar: noop,
renameInspectVarName: noop,
deleteInspectVar: noop,
setWorkflowCanvasWidth: noop,
setWorkflowCanvasHeight: noop,
setRightPanelWidth: noop,
setNodePanelWidth: noop,
setPreviewPanelWidth: noop,
setOtherPanelWidth: noop,
setBottomPanelWidth: noop,
setBottomPanelHeight: noop,
setVariableInspectPanelHeight: noop,
setMaximizeCanvas: noop,
...overrides,
}
}

View File

@ -0,0 +1,143 @@
/**
* Shared mutable ReactFlow mock state for hook/component tests.
*
* Mutate `rfState` in `beforeEach` to configure nodes/edges,
* then assert on `rfState.setNodes`, `rfState.setEdges`, etc.
*
* Usage (one line at top of test file):
* ```ts
* vi.mock('reactflow', async () =>
* (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(),
* )
* ```
*/
import * as React from 'react'
type MockNode = {
id: string
position: { x: number, y: number }
width?: number
height?: number
parentId?: string
data: Record<string, unknown>
}
type MockEdge = {
id: string
source: string
target: string
sourceHandle?: string
data: Record<string, unknown>
}
type ReactFlowMockState = {
nodes: MockNode[]
edges: MockEdge[]
transform: [number, number, number]
setViewport: ReturnType<typeof vi.fn>
setNodes: ReturnType<typeof vi.fn>
setEdges: ReturnType<typeof vi.fn>
}
export const rfState: ReactFlowMockState = {
nodes: [],
edges: [],
transform: [0, 0, 1],
setViewport: vi.fn(),
setNodes: vi.fn(),
setEdges: vi.fn(),
}
export function resetReactFlowMockState() {
rfState.nodes = []
rfState.edges = []
rfState.transform = [0, 0, 1]
rfState.setViewport.mockReset()
rfState.setNodes.mockReset()
rfState.setEdges.mockReset()
}
export function createReactFlowModuleMock() {
return {
Position: { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' },
MarkerType: { Arrow: 'arrow', ArrowClosed: 'arrowclosed' },
ConnectionMode: { Strict: 'strict', Loose: 'loose' },
useStoreApi: vi.fn(() => ({
getState: () => ({
getNodes: () => rfState.nodes,
setNodes: rfState.setNodes,
edges: rfState.edges,
setEdges: rfState.setEdges,
transform: rfState.transform,
nodeInternals: new Map(),
d3Selection: null,
d3Zoom: null,
}),
setState: vi.fn(),
subscribe: vi.fn().mockReturnValue(vi.fn()),
})),
useReactFlow: vi.fn(() => ({
setViewport: rfState.setViewport,
setCenter: vi.fn(),
fitView: vi.fn(),
zoomIn: vi.fn(),
zoomOut: vi.fn(),
zoomTo: vi.fn(),
getNodes: () => rfState.nodes,
getEdges: () => rfState.edges,
setNodes: rfState.setNodes,
setEdges: rfState.setEdges,
getViewport: () => ({ x: 0, y: 0, zoom: 1 }),
screenToFlowPosition: (pos: { x: number, y: number }) => pos,
flowToScreenPosition: (pos: { x: number, y: number }) => pos,
deleteElements: vi.fn(),
addNodes: vi.fn(),
addEdges: vi.fn(),
getNode: vi.fn(),
toObject: vi.fn().mockReturnValue({ nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }),
viewportInitialized: true,
})),
useStore: vi.fn().mockReturnValue(null),
useNodes: vi.fn(() => rfState.nodes),
useEdges: vi.fn(() => rfState.edges),
useViewport: vi.fn(() => ({ x: 0, y: 0, zoom: 1 })),
useKeyPress: vi.fn(() => false),
useOnSelectionChange: vi.fn(),
useOnViewportChange: vi.fn(),
useUpdateNodeInternals: vi.fn(() => vi.fn()),
useNodeId: vi.fn(() => null),
useNodesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]),
useEdgesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]),
ReactFlowProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
ReactFlow: ({ children }: { children?: React.ReactNode }) =>
React.createElement('div', { 'data-testid': 'reactflow-mock' }, children),
Background: () => null,
MiniMap: () => null,
Controls: () => null,
Handle: (props: Record<string, unknown>) => React.createElement('div', props),
BaseEdge: (props: Record<string, unknown>) => React.createElement('path', props),
EdgeLabelRenderer: ({ children }: { children?: React.ReactNode }) =>
React.createElement('div', null, children),
getOutgoers: vi.fn().mockReturnValue([]),
getIncomers: vi.fn().mockReturnValue([]),
getConnectedEdges: vi.fn().mockReturnValue([]),
isNode: vi.fn().mockReturnValue(true),
isEdge: vi.fn().mockReturnValue(false),
addEdge: vi.fn().mockImplementation((_e: unknown, edges: unknown[]) => edges),
applyNodeChanges: vi.fn().mockImplementation((_c: unknown[], nodes: unknown[]) => nodes),
applyEdgeChanges: vi.fn().mockImplementation((_c: unknown[], edges: unknown[]) => edges),
getBezierPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
getSmoothStepPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
getStraightPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
internalsSymbol: Symbol('internals'),
}
}
export type { MockEdge, MockNode, ReactFlowMockState }

View File

@ -0,0 +1,75 @@
/**
* Centralized mock factories for external services used by workflow.
*
* Usage:
* ```ts
* vi.mock('@/service/use-tools', async () =>
* (await import('../../__tests__/service-mock-factory')).createToolServiceMock(),
* )
* vi.mock('@/app/components/app/store', async () =>
* (await import('../../__tests__/service-mock-factory')).createAppStoreMock(),
* )
* ```
*/
// ---------------------------------------------------------------------------
// App store
// ---------------------------------------------------------------------------
type AppStoreMockData = {
appId?: string
appMode?: string
}
export function createAppStoreMock(data?: AppStoreMockData) {
return {
useStore: {
getState: () => ({
appDetail: {
id: data?.appId ?? 'app-test-id',
mode: data?.appMode ?? 'workflow',
},
}),
},
}
}
// ---------------------------------------------------------------------------
// SWR service hooks
// ---------------------------------------------------------------------------
type ToolMockData = {
buildInTools?: unknown[]
customTools?: unknown[]
workflowTools?: unknown[]
mcpTools?: unknown[]
}
type TriggerMockData = {
triggerPlugins?: unknown[]
}
type StrategyMockData = {
strategyProviders?: unknown[]
}
export function createToolServiceMock(data?: ToolMockData) {
return {
useAllBuiltInTools: vi.fn(() => ({ data: data?.buildInTools ?? [] })),
useAllCustomTools: vi.fn(() => ({ data: data?.customTools ?? [] })),
useAllWorkflowTools: vi.fn(() => ({ data: data?.workflowTools ?? [] })),
useAllMCPTools: vi.fn(() => ({ data: data?.mcpTools ?? [] })),
}
}
export function createTriggerServiceMock(data?: TriggerMockData) {
return {
useAllTriggerPlugins: vi.fn(() => ({ data: data?.triggerPlugins ?? [] })),
}
}
export function createStrategyServiceMock(data?: StrategyMockData) {
return {
useStrategyProviders: vi.fn(() => ({ data: data?.strategyProviders ?? [] })),
}
}

View File

@ -276,7 +276,7 @@ describe('Trigger Status Synchronization Integration', () => {
nodeId: string
nodeType: string
}> = ({ nodeId, nodeType }) => {
const triggerStatusSelector = useCallback((state: any) =>
const triggerStatusSelector = useCallback((state: { triggerStatuses: Record<string, string> }) =>
mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled', [nodeId, nodeType])
const triggerStatus = useTriggerStatusStore(triggerStatusSelector)
@ -319,9 +319,9 @@ describe('Trigger Status Synchronization Integration', () => {
const TestComponent: React.FC<{ nodeType: string }> = ({ nodeType }) => {
const triggerStatusSelector = useCallback(
(state: any) =>
(state: { triggerStatuses: Record<string, string> }) =>
mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses['test-node'] || 'disabled') : 'enabled',
['test-node', nodeType], // Dependencies should match implementation
[nodeType],
)
const status = useTriggerStatusStore(triggerStatusSelector)
return <div data-testid="test-component" data-status={status} />

View File

@ -0,0 +1,195 @@
/**
* Workflow test environment — composable providers + render helpers.
*
* ## Quick start
*
* ```ts
* import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
* import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
*
* // Mock ReactFlow (one line, only needed when the hook imports reactflow)
* vi.mock('reactflow', async () =>
* (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(),
* )
*
* it('example', () => {
* resetReactFlowMockState()
* rfState.nodes = [{ id: 'n1', position: { x: 0, y: 0 }, data: {} }]
*
* const { result, store } = renderWorkflowHook(
* () => useMyHook(),
* {
* initialStoreState: { workflowRunningData: {...} },
* hooksStoreProps: { doSyncWorkflowDraft: vi.fn() },
* },
* )
*
* result.current.doSomething()
* expect(store.getState().someValue).toBe(expected)
* expect(rfState.setNodes).toHaveBeenCalled()
* })
* ```
*/
import type { RenderHookOptions, RenderHookResult } from '@testing-library/react'
import type { Shape as HooksStoreShape } from '../hooks-store/store'
import type { Shape } from '../store/workflow'
import type { Edge, Node, WorkflowRunningData } from '../types'
import type { WorkflowHistoryStoreApi } from '../workflow-history-store'
import { renderHook } from '@testing-library/react'
import isDeepEqual from 'fast-deep-equal'
import * as React from 'react'
import { temporal } from 'zundo'
import { create } from 'zustand'
import { WorkflowContext } from '../context'
import { HooksStoreContext } from '../hooks-store/provider'
import { createHooksStore } from '../hooks-store/store'
import { createWorkflowStore } from '../store/workflow'
import { WorkflowRunningStatus } from '../types'
import { WorkflowHistoryStoreContext } from '../workflow-history-store'
// Re-exports are in a separate non-JSX file to avoid react-refresh warnings.
// Import directly from the individual modules:
// reactflow-mock-state.ts → rfState, resetReactFlowMockState, createReactFlowModuleMock
// service-mock-factory.ts → createToolServiceMock, createTriggerServiceMock, ...
// fixtures.ts → createNode, createEdge, createLinearGraph, ...
// ---------------------------------------------------------------------------
// Test data factories
// ---------------------------------------------------------------------------
export function baseRunningData(overrides: Record<string, unknown> = {}) {
return {
task_id: 'task-1',
result: { status: WorkflowRunningStatus.Running } as WorkflowRunningData['result'],
tracing: [],
resultText: '',
resultTabActive: false,
...overrides,
} as WorkflowRunningData
}
// ---------------------------------------------------------------------------
// Store creation helpers
// ---------------------------------------------------------------------------
type WorkflowStore = ReturnType<typeof createWorkflowStore>
type HooksStore = ReturnType<typeof createHooksStore>
export function createTestWorkflowStore(initialState?: Partial<Shape>): WorkflowStore {
const store = createWorkflowStore({})
if (initialState)
store.setState(initialState)
return store
}
export function createTestHooksStore(props?: Partial<HooksStoreShape>): HooksStore {
return createHooksStore(props ?? {})
}
// ---------------------------------------------------------------------------
// renderWorkflowHook — composable hook renderer
// ---------------------------------------------------------------------------
type HistoryStoreConfig = {
nodes?: Node[]
edges?: Edge[]
}
type WorkflowTestOptions<P> = Omit<RenderHookOptions<P>, 'wrapper'> & {
initialStoreState?: Partial<Shape>
hooksStoreProps?: Partial<HooksStoreShape>
historyStore?: HistoryStoreConfig
}
type WorkflowTestResult<R, P> = RenderHookResult<R, P> & {
store: WorkflowStore
hooksStore?: HooksStore
}
/**
* Renders a hook inside composable workflow providers.
*
* Contexts provided based on options:
* - **Always**: `WorkflowContext` (real zustand store)
* - **hooksStoreProps**: `HooksStoreContext` (real zustand store)
* - **historyStore**: `WorkflowHistoryStoreContext` (real zundo temporal store)
*/
export function renderWorkflowHook<R, P = undefined>(
hook: (props: P) => R,
options?: WorkflowTestOptions<P>,
): WorkflowTestResult<R, P> {
const { initialStoreState, hooksStoreProps, historyStore: historyConfig, ...rest } = options ?? {}
const store = createTestWorkflowStore(initialStoreState)
const hooksStore = hooksStoreProps !== undefined
? createTestHooksStore(hooksStoreProps)
: undefined
const wrapper = ({ children }: { children: React.ReactNode }) => {
let inner: React.ReactNode = children
if (historyConfig) {
const historyCtxValue = createTestHistoryStoreContext(historyConfig)
inner = React.createElement(
WorkflowHistoryStoreContext.Provider,
{ value: historyCtxValue },
inner,
)
}
if (hooksStore) {
inner = React.createElement(
HooksStoreContext.Provider,
{ value: hooksStore },
inner,
)
}
return React.createElement(
WorkflowContext.Provider,
{ value: store },
inner,
)
}
const renderResult = renderHook(hook, { wrapper, ...rest })
return { ...renderResult, store, hooksStore }
}
// ---------------------------------------------------------------------------
// WorkflowHistoryStore test helper
// ---------------------------------------------------------------------------
function createTestHistoryStoreContext(config: HistoryStoreConfig) {
const nodes = config.nodes ?? []
const edges = config.edges ?? []
type HistState = {
workflowHistoryEvent: string | undefined
workflowHistoryEventMeta: unknown
nodes: Node[]
edges: Edge[]
getNodes: () => Node[]
setNodes: (n: Node[]) => void
setEdges: (e: Edge[]) => void
}
const store = create(temporal<HistState>(
(set, get) => ({
workflowHistoryEvent: undefined,
workflowHistoryEventMeta: undefined,
nodes,
edges,
getNodes: () => get().nodes,
setNodes: (n: Node[]) => set({ nodes: n }),
setEdges: (e: Edge[]) => set({ edges: e }),
}),
{ equality: (a, b) => isDeepEqual(a, b) },
)) as unknown as WorkflowHistoryStoreApi
return {
store,
shortcutsEnabled: true,
setShortcutsEnabled: () => {},
}
}

View File

@ -0,0 +1,83 @@
import { renderHook } from '@testing-library/react'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { BlockEnum } from '../../types'
import { useAutoGenerateWebhookUrl } from '../use-auto-generate-webhook-url'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
vi.mock('@/app/components/app/store', async () =>
(await import('../../__tests__/service-mock-factory')).createAppStoreMock({ appId: 'app-123' }))
const mockFetchWebhookUrl = vi.fn()
vi.mock('@/service/apps', () => ({
fetchWebhookUrl: (...args: unknown[]) => mockFetchWebhookUrl(...args),
}))
describe('useAutoGenerateWebhookUrl', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
rfState.nodes = [
{ id: 'webhook-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.TriggerWebhook, webhook_url: '' } },
{ id: 'code-1', position: { x: 300, y: 0 }, data: { type: BlockEnum.Code } },
]
})
it('should fetch and set webhook URL for a webhook trigger node', async () => {
mockFetchWebhookUrl.mockResolvedValue({
webhook_url: 'https://example.com/webhook',
webhook_debug_url: 'https://example.com/webhook-debug',
})
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('webhook-1')
expect(mockFetchWebhookUrl).toHaveBeenCalledWith({ appId: 'app-123', nodeId: 'webhook-1' })
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const webhookNode = updatedNodes.find((n: { id: string }) => n.id === 'webhook-1')
expect(webhookNode.data.webhook_url).toBe('https://example.com/webhook')
expect(webhookNode.data.webhook_debug_url).toBe('https://example.com/webhook-debug')
})
it('should not fetch when node is not a webhook trigger', async () => {
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('code-1')
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
expect(rfState.setNodes).not.toHaveBeenCalled()
})
it('should not fetch when node does not exist', async () => {
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('nonexistent')
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
})
it('should not fetch when webhook_url already exists', async () => {
rfState.nodes[0].data.webhook_url = 'https://existing.com/webhook'
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('webhook-1')
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
})
it('should handle API errors gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetchWebhookUrl.mockRejectedValue(new Error('network error'))
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('webhook-1')
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to auto-generate webhook URL:',
expect.any(Error),
)
expect(rfState.setNodes).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
})

View File

@ -0,0 +1,162 @@
import type { NodeDefault } from '../../types'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { BlockClassificationEnum } from '../../block-selector/types'
import { BlockEnum } from '../../types'
import { useAvailableBlocks } from '../use-available-blocks'
// Transitive imports of use-nodes-meta-data.ts — only useNodeMetaData uses these
vi.mock('@/service/use-tools', async () =>
(await import('../../__tests__/service-mock-factory')).createToolServiceMock())
vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en' }))
const mockNodeTypes = [
BlockEnum.Start,
BlockEnum.End,
BlockEnum.LLM,
BlockEnum.Code,
BlockEnum.IfElse,
BlockEnum.Iteration,
BlockEnum.Loop,
BlockEnum.Tool,
BlockEnum.DataSource,
BlockEnum.KnowledgeBase,
BlockEnum.HumanInput,
BlockEnum.LoopEnd,
]
function createNodeDefault(type: BlockEnum): NodeDefault {
return {
metaData: {
classification: BlockClassificationEnum.Default,
sort: 0,
type,
title: type,
author: 'test',
},
defaultValue: {},
checkValid: () => ({ isValid: true }),
}
}
const hooksStoreProps = {
availableNodesMetaData: {
nodes: mockNodeTypes.map(createNodeDefault),
},
}
describe('useAvailableBlocks', () => {
describe('availablePrevBlocks', () => {
it('should return empty array when nodeType is undefined', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(undefined), { hooksStoreProps })
expect(result.current.availablePrevBlocks).toEqual([])
})
it('should return empty array for Start node', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.Start), { hooksStoreProps })
expect(result.current.availablePrevBlocks).toEqual([])
})
it('should return empty array for trigger nodes', () => {
for (const trigger of [BlockEnum.TriggerPlugin, BlockEnum.TriggerWebhook, BlockEnum.TriggerSchedule]) {
const { result } = renderWorkflowHook(() => useAvailableBlocks(trigger), { hooksStoreProps })
expect(result.current.availablePrevBlocks).toEqual([])
}
})
it('should return empty array for DataSource node', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.DataSource), { hooksStoreProps })
expect(result.current.availablePrevBlocks).toEqual([])
})
it('should return all available nodes for regular block types', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps })
expect(result.current.availablePrevBlocks.length).toBeGreaterThan(0)
expect(result.current.availablePrevBlocks).toContain(BlockEnum.Code)
})
})
describe('availableNextBlocks', () => {
it('should return empty array when nodeType is undefined', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(undefined), { hooksStoreProps })
expect(result.current.availableNextBlocks).toEqual([])
})
it('should return empty array for End node', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.End), { hooksStoreProps })
expect(result.current.availableNextBlocks).toEqual([])
})
it('should return empty array for LoopEnd node', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LoopEnd), { hooksStoreProps })
expect(result.current.availableNextBlocks).toEqual([])
})
it('should return empty array for KnowledgeBase node', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.KnowledgeBase), { hooksStoreProps })
expect(result.current.availableNextBlocks).toEqual([])
})
it('should return all available nodes for regular block types', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps })
expect(result.current.availableNextBlocks.length).toBeGreaterThan(0)
})
})
describe('inContainer filtering', () => {
it('should exclude Iteration, Loop, End, DataSource, KnowledgeBase, HumanInput when inContainer=true', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM, true), { hooksStoreProps })
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.Iteration)
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.Loop)
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.End)
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.DataSource)
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.KnowledgeBase)
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.HumanInput)
})
it('should exclude LoopEnd when not in container', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM, false), { hooksStoreProps })
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.LoopEnd)
})
})
describe('getAvailableBlocks callback', () => {
it('should return prev and next blocks for a given node type', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps })
const blocks = result.current.getAvailableBlocks(BlockEnum.Code)
expect(blocks.availablePrevBlocks.length).toBeGreaterThan(0)
expect(blocks.availableNextBlocks.length).toBeGreaterThan(0)
})
it('should return empty prevBlocks for Start node', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps })
const blocks = result.current.getAvailableBlocks(BlockEnum.Start)
expect(blocks.availablePrevBlocks).toEqual([])
})
it('should return empty prevBlocks for DataSource node', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps })
const blocks = result.current.getAvailableBlocks(BlockEnum.DataSource)
expect(blocks.availablePrevBlocks).toEqual([])
})
it('should return empty nextBlocks for End/LoopEnd/KnowledgeBase', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps })
expect(result.current.getAvailableBlocks(BlockEnum.End).availableNextBlocks).toEqual([])
expect(result.current.getAvailableBlocks(BlockEnum.LoopEnd).availableNextBlocks).toEqual([])
expect(result.current.getAvailableBlocks(BlockEnum.KnowledgeBase).availableNextBlocks).toEqual([])
})
it('should filter by inContainer when provided', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps })
const blocks = result.current.getAvailableBlocks(BlockEnum.Code, true)
expect(blocks.availableNextBlocks).not.toContain(BlockEnum.Iteration)
expect(blocks.availableNextBlocks).not.toContain(BlockEnum.Loop)
})
})
})

View File

@ -0,0 +1,312 @@
import type { CommonNodeType, Node } from '../../types'
import type { ChecklistItem } from '../use-checklist'
import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import { useChecklist, useWorkflowRunValidation } from '../use-checklist'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock('reactflow', async () => {
const base = (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()
return {
...base,
getOutgoers: vi.fn((node: Node, nodes: Node[], edges: { source: string, target: string }[]) => {
return edges
.filter(e => e.source === node.id)
.map(e => nodes.find(n => n.id === e.target))
.filter(Boolean)
}),
}
})
vi.mock('@/service/use-tools', async () =>
(await import('../../__tests__/service-mock-factory')).createToolServiceMock())
vi.mock('@/service/use-triggers', async () =>
(await import('../../__tests__/service-mock-factory')).createTriggerServiceMock())
vi.mock('@/service/use-strategy', () => ({
useStrategyProviders: () => ({ data: [] }),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({ data: [] }),
}))
type CheckValidFn = (data: CommonNodeType, t: unknown, extra?: unknown) => { errorMessage: string }
const mockNodesMap: Record<string, { checkValid: CheckValidFn, metaData: { isStart: boolean, isRequired: boolean } }> = {}
vi.mock('../use-nodes-meta-data', () => ({
useNodesMetaData: () => ({
nodes: [],
nodesMap: mockNodesMap,
}),
}))
vi.mock('../use-nodes-available-var-list', () => ({
default: (nodes: Node[]) => {
const map: Record<string, { availableVars: never[] }> = {}
if (nodes) {
for (const n of nodes)
map[n.id] = { availableVars: [] }
}
return map
},
useGetNodesAvailableVarList: () => ({ getNodesAvailableVarList: vi.fn(() => ({})) }),
}))
vi.mock('../../nodes/_base/components/variable/utils', () => ({
getNodeUsedVars: () => [],
isSpecialVar: () => false,
}))
vi.mock('@/app/components/app/store', () => {
const state = { appDetail: { mode: 'workflow' } }
return {
useStore: {
getState: () => state,
},
}
})
vi.mock('../../datasets-detail-store/store', () => ({
useDatasetsDetailStore: () => ({}),
}))
vi.mock('../index', () => ({
useGetToolIcon: () => () => undefined,
useNodesMetaData: () => ({ nodes: [], nodesMap: mockNodesMap }),
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: vi.fn() }),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en',
}))
// useWorkflowNodes reads from WorkflowContext (real store via renderWorkflowHook)
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
function setupNodesMap() {
mockNodesMap[BlockEnum.Start] = {
checkValid: () => ({ errorMessage: '' }),
metaData: { isStart: true, isRequired: false },
}
mockNodesMap[BlockEnum.Code] = {
checkValid: () => ({ errorMessage: '' }),
metaData: { isStart: false, isRequired: false },
}
mockNodesMap[BlockEnum.LLM] = {
checkValid: () => ({ errorMessage: '' }),
metaData: { isStart: false, isRequired: false },
}
mockNodesMap[BlockEnum.End] = {
checkValid: () => ({ errorMessage: '' }),
metaData: { isStart: false, isRequired: false },
}
mockNodesMap[BlockEnum.Tool] = {
checkValid: () => ({ errorMessage: '' }),
metaData: { isStart: false, isRequired: false },
}
}
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
resetFixtureCounters()
Object.keys(mockNodesMap).forEach(k => delete mockNodesMap[k])
setupNodesMap()
})
// ---------------------------------------------------------------------------
// Helper: build a simple connected graph
// ---------------------------------------------------------------------------
function buildConnectedGraph() {
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } })
const endNode = createNode({ id: 'end', data: { type: BlockEnum.End, title: 'End' } })
const nodes = [startNode, codeNode, endNode]
const edges = [
createEdge({ source: 'start', target: 'code' }),
createEdge({ source: 'code', target: 'end' }),
]
return { nodes, edges }
}
// ---------------------------------------------------------------------------
// useChecklist
// ---------------------------------------------------------------------------
describe('useChecklist', () => {
it('should return empty list when all nodes are valid and connected', () => {
const { nodes, edges } = buildConnectedGraph()
const { result } = renderWorkflowHook(
() => useChecklist(nodes, edges),
)
expect(result.current).toEqual([])
})
it('should detect disconnected nodes', () => {
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } })
const isolatedLlm = createNode({ id: 'llm', data: { type: BlockEnum.LLM, title: 'LLM' } })
const edges = [
createEdge({ source: 'start', target: 'code' }),
]
const { result } = renderWorkflowHook(
() => useChecklist([startNode, codeNode, isolatedLlm], edges),
)
const warning = result.current.find((item: ChecklistItem) => item.id === 'llm')
expect(warning).toBeDefined()
expect(warning!.unConnected).toBe(true)
})
it('should detect validation errors from checkValid', () => {
mockNodesMap[BlockEnum.LLM] = {
checkValid: () => ({ errorMessage: 'Model not configured' }),
metaData: { isStart: false, isRequired: false },
}
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
const llmNode = createNode({ id: 'llm', data: { type: BlockEnum.LLM, title: 'LLM' } })
const edges = [
createEdge({ source: 'start', target: 'llm' }),
]
const { result } = renderWorkflowHook(
() => useChecklist([startNode, llmNode], edges),
)
const warning = result.current.find((item: ChecklistItem) => item.id === 'llm')
expect(warning).toBeDefined()
expect(warning!.errorMessage).toBe('Model not configured')
})
it('should report missing start node in workflow mode', () => {
const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } })
const { result } = renderWorkflowHook(
() => useChecklist([codeNode], []),
)
const startRequired = result.current.find((item: ChecklistItem) => item.id === 'start-node-required')
expect(startRequired).toBeDefined()
expect(startRequired!.canNavigate).toBe(false)
})
it('should detect plugin not installed', () => {
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
const toolNode = createNode({
id: 'tool',
data: {
type: BlockEnum.Tool,
title: 'My Tool',
_pluginInstallLocked: true,
},
})
const edges = [
createEdge({ source: 'start', target: 'tool' }),
]
const { result } = renderWorkflowHook(
() => useChecklist([startNode, toolNode], edges),
)
const warning = result.current.find((item: ChecklistItem) => item.id === 'tool')
expect(warning).toBeDefined()
expect(warning!.canNavigate).toBe(false)
expect(warning!.disableGoTo).toBe(true)
})
it('should report required node types that are missing', () => {
mockNodesMap[BlockEnum.End] = {
checkValid: () => ({ errorMessage: '' }),
metaData: { isStart: false, isRequired: true },
}
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
const { result } = renderWorkflowHook(
() => useChecklist([startNode], []),
)
const requiredItem = result.current.find((item: ChecklistItem) => item.id === `${BlockEnum.End}-need-added`)
expect(requiredItem).toBeDefined()
expect(requiredItem!.canNavigate).toBe(false)
})
it('should not flag start nodes as unconnected', () => {
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } })
const { result } = renderWorkflowHook(
() => useChecklist([startNode, codeNode], []),
)
const startWarning = result.current.find((item: ChecklistItem) => item.id === 'start')
expect(startWarning).toBeUndefined()
})
it('should skip nodes without CUSTOM_NODE type', () => {
const nonCustomNode = createNode({
id: 'alien',
type: 'not-custom',
data: { type: BlockEnum.Code, title: 'Non-Custom' },
})
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
const { result } = renderWorkflowHook(
() => useChecklist([startNode, nonCustomNode], []),
)
const alienWarning = result.current.find((item: ChecklistItem) => item.id === 'alien')
expect(alienWarning).toBeUndefined()
})
})
// ---------------------------------------------------------------------------
// useWorkflowRunValidation
// ---------------------------------------------------------------------------
describe('useWorkflowRunValidation', () => {
it('should return hasValidationErrors false when there are no warnings', () => {
const { nodes, edges } = buildConnectedGraph()
rfState.edges = edges as unknown as typeof rfState.edges
const { result } = renderWorkflowHook(() => useWorkflowRunValidation(), {
initialStoreState: { nodes: nodes as Node[] },
})
expect(result.current.hasValidationErrors).toBe(false)
expect(result.current.warningNodes).toEqual([])
})
it('should return validateBeforeRun as a function that returns true when valid', () => {
const { nodes, edges } = buildConnectedGraph()
rfState.edges = edges as unknown as typeof rfState.edges
const { result } = renderWorkflowHook(() => useWorkflowRunValidation(), {
initialStoreState: { nodes: nodes as Node[] },
})
expect(typeof result.current.validateBeforeRun).toBe('function')
expect(result.current.validateBeforeRun()).toBe(true)
})
})

View File

@ -0,0 +1,151 @@
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { useEdgesInteractions } from '../use-edges-interactions'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
// useWorkflowHistory uses a debounced save — mock for synchronous assertions
const mockSaveStateToHistory = vi.fn()
vi.mock('../use-workflow-history', () => ({
useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory }),
WorkflowHistoryEvent: {
EdgeDelete: 'EdgeDelete',
EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch',
EdgeSourceHandleChange: 'EdgeSourceHandleChange',
},
}))
// use-workflow.ts has heavy transitive imports — mock only useNodesReadOnly
let mockReadOnly = false
vi.mock('../use-workflow', () => ({
useNodesReadOnly: () => ({
getNodesReadOnly: () => mockReadOnly,
}),
}))
vi.mock('../../utils', () => ({
getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})),
}))
// useNodesSyncDraft is used REAL — via renderWorkflowHook + hooksStoreProps
function renderEdgesInteractions() {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
return {
...renderWorkflowHook(() => useEdgesInteractions(), {
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
}),
mockDoSync,
}
}
describe('useEdgesInteractions', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
mockReadOnly = false
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
{ id: 'n2', position: { x: 100, y: 0 }, data: {} },
]
rfState.edges = [
{ id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', data: { _hovering: false } },
{ id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', data: { _hovering: false } },
]
})
it('handleEdgeEnter should set _hovering to true', () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgeEnter({} as never, rfState.edges[0] as never)
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated.find((e: { id: string }) => e.id === 'e1').data._hovering).toBe(true)
expect(updated.find((e: { id: string }) => e.id === 'e2').data._hovering).toBe(false)
})
it('handleEdgeLeave should set _hovering to false', () => {
rfState.edges[0].data._hovering = true
const { result } = renderEdgesInteractions()
result.current.handleEdgeLeave({} as never, rfState.edges[0] as never)
expect(rfState.setEdges.mock.calls[0][0].find((e: { id: string }) => e.id === 'e1').data._hovering).toBe(false)
})
it('handleEdgesChange should update edge.selected for select changes', () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgesChange([
{ type: 'select', id: 'e1', selected: true },
{ type: 'select', id: 'e2', selected: false },
])
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated.find((e: { id: string }) => e.id === 'e1').selected).toBe(true)
expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(false)
})
it('handleEdgeDelete should remove selected edge and trigger sync + history', () => {
;(rfState.edges[0] as Record<string, unknown>).selected = true
const { result } = renderEdgesInteractions()
result.current.handleEdgeDelete()
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated).toHaveLength(1)
expect(updated[0].id).toBe('e2')
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
})
it('handleEdgeDelete should do nothing when no edge is selected', () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgeDelete()
expect(rfState.setEdges).not.toHaveBeenCalled()
})
it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated).toHaveLength(1)
expect(updated[0].id).toBe('e2')
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDeleteByDeleteBranch')
})
it('handleEdgeSourceHandleChange should update sourceHandle and edge ID', () => {
rfState.edges = [
{ id: 'n1-old-handle-n2-target', source: 'n1', target: 'n2', sourceHandle: 'old-handle', targetHandle: 'target', data: {} } as typeof rfState.edges[0],
]
const { result } = renderEdgesInteractions()
result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle')
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated[0].sourceHandle).toBe('new-handle')
expect(updated[0].id).toBe('n1-new-handle-n2-target')
})
describe('read-only mode', () => {
beforeEach(() => {
mockReadOnly = true
})
it('handleEdgeEnter should do nothing', () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgeEnter({} as never, rfState.edges[0] as never)
expect(rfState.setEdges).not.toHaveBeenCalled()
})
it('handleEdgeDelete should do nothing', () => {
;(rfState.edges[0] as Record<string, unknown>).selected = true
const { result } = renderEdgesInteractions()
result.current.handleEdgeDelete()
expect(rfState.setEdges).not.toHaveBeenCalled()
})
it('handleEdgeDeleteByDeleteBranch should do nothing', () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
expect(rfState.setEdges).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,194 @@
import type { Node } from '../../types'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import { useHelpline } from '../use-helpline'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
function makeNode(overrides: Record<string, unknown> & { id: string }): Node {
return {
position: { x: 0, y: 0 },
width: 240,
height: 100,
data: { type: BlockEnum.LLM, title: '', desc: '' },
...overrides,
} as unknown as Node
}
describe('useHelpline', () => {
beforeEach(() => {
resetReactFlowMockState()
})
it('should return empty arrays for nodes in iteration', () => {
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
{ id: 'n2', position: { x: 0, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
]
const { result } = renderWorkflowHook(() => useHelpline())
const draggingNode = makeNode({ id: 'n1', data: { type: BlockEnum.LLM, isInIteration: true } })
const output = result.current.handleSetHelpline(draggingNode)
expect(output.showHorizontalHelpLineNodes).toEqual([])
expect(output.showVerticalHelpLineNodes).toEqual([])
})
it('should return empty arrays for nodes in loop', () => {
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
]
const { result } = renderWorkflowHook(() => useHelpline())
const draggingNode = makeNode({ id: 'n1', data: { type: BlockEnum.LLM, isInLoop: true } })
const output = result.current.handleSetHelpline(draggingNode)
expect(output.showHorizontalHelpLineNodes).toEqual([])
expect(output.showVerticalHelpLineNodes).toEqual([])
})
it('should detect horizontally aligned nodes (same y ±5px)', () => {
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
{ id: 'n2', position: { x: 300, y: 103 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
{ id: 'n3', position: { x: 600, y: 500 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
]
const { result } = renderWorkflowHook(() => useHelpline())
const draggingNode = makeNode({ id: 'n1', position: { x: 0, y: 100 } })
const output = result.current.handleSetHelpline(draggingNode)
const horizontalIds = output.showHorizontalHelpLineNodes.map((n: { id: string }) => n.id)
expect(horizontalIds).toContain('n2')
expect(horizontalIds).not.toContain('n3')
})
it('should detect vertically aligned nodes (same x ±5px)', () => {
rfState.nodes = [
{ id: 'n1', position: { x: 100, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
{ id: 'n2', position: { x: 102, y: 200 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
{ id: 'n3', position: { x: 500, y: 400 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
]
const { result } = renderWorkflowHook(() => useHelpline())
const draggingNode = makeNode({ id: 'n1', position: { x: 100, y: 0 } })
const output = result.current.handleSetHelpline(draggingNode)
const verticalIds = output.showVerticalHelpLineNodes.map((n: { id: string }) => n.id)
expect(verticalIds).toContain('n2')
expect(verticalIds).not.toContain('n3')
})
it('should apply entry node offset for Start nodes', () => {
const ENTRY_OFFSET_Y = 21
rfState.nodes = [
{ id: 'start', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.Start } },
{ id: 'n2', position: { x: 300, y: 100 + ENTRY_OFFSET_Y }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
{ id: 'far', position: { x: 300, y: 500 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
]
const { result } = renderWorkflowHook(() => useHelpline())
const draggingNode = makeNode({
id: 'start',
position: { x: 100, y: 100 },
width: 240,
height: 100,
data: { type: BlockEnum.Start },
})
const output = result.current.handleSetHelpline(draggingNode)
const horizontalIds = output.showHorizontalHelpLineNodes.map((n: { id: string }) => n.id)
expect(horizontalIds).toContain('n2')
expect(horizontalIds).not.toContain('far')
})
it('should apply entry node offset for Trigger nodes', () => {
const ENTRY_OFFSET_Y = 21
rfState.nodes = [
{ id: 'trigger', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.TriggerWebhook } },
{ id: 'n2', position: { x: 300, y: 100 + ENTRY_OFFSET_Y }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
]
const { result } = renderWorkflowHook(() => useHelpline())
const draggingNode = makeNode({
id: 'trigger',
position: { x: 100, y: 100 },
width: 240,
height: 100,
data: { type: BlockEnum.TriggerWebhook },
})
const output = result.current.handleSetHelpline(draggingNode)
const horizontalIds = output.showHorizontalHelpLineNodes.map((n: { id: string }) => n.id)
expect(horizontalIds).toContain('n2')
})
it('should not detect alignment when positions differ by more than 5px', () => {
rfState.nodes = [
{ id: 'n1', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
{ id: 'n2', position: { x: 300, y: 106 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
{ id: 'n3', position: { x: 106, y: 300 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
]
const { result } = renderWorkflowHook(() => useHelpline())
const draggingNode = makeNode({ id: 'n1', position: { x: 100, y: 100 } })
const output = result.current.handleSetHelpline(draggingNode)
expect(output.showHorizontalHelpLineNodes).toHaveLength(0)
expect(output.showVerticalHelpLineNodes).toHaveLength(0)
})
it('should exclude child nodes in iteration', () => {
rfState.nodes = [
{ id: 'n1', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
{ id: 'child', position: { x: 300, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM, isInIteration: true } },
]
const { result } = renderWorkflowHook(() => useHelpline())
const draggingNode = makeNode({ id: 'n1', position: { x: 100, y: 100 } })
const output = result.current.handleSetHelpline(draggingNode)
const horizontalIds = output.showHorizontalHelpLineNodes.map((n: { id: string }) => n.id)
expect(horizontalIds).not.toContain('child')
})
it('should set helpLineHorizontal in store when aligned nodes found', () => {
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
{ id: 'n2', position: { x: 300, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
]
const { result, store } = renderWorkflowHook(() => useHelpline())
const draggingNode = makeNode({ id: 'n1', position: { x: 0, y: 100 } })
result.current.handleSetHelpline(draggingNode)
expect(store.getState().helpLineHorizontal).toBeDefined()
})
it('should clear helpLineHorizontal when no aligned nodes', () => {
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
{ id: 'n2', position: { x: 300, y: 500 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
]
const { result, store } = renderWorkflowHook(() => useHelpline())
const draggingNode = makeNode({ id: 'n1', position: { x: 0, y: 100 } })
result.current.handleSetHelpline(draggingNode)
expect(store.getState().helpLineHorizontal).toBeUndefined()
})
})

View File

@ -0,0 +1,79 @@
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { useDSL } from '../use-DSL'
import { useWorkflowRefreshDraft } from '../use-workflow-refresh-draft'
import { useWorkflowRun } from '../use-workflow-run'
import { useWorkflowStartRun } from '../use-workflow-start-run'
describe('useDSL', () => {
it('should return exportCheck and handleExportDSL from hooksStore', () => {
const mockExportCheck = vi.fn()
const mockHandleExportDSL = vi.fn()
const { result } = renderWorkflowHook(() => useDSL(), {
hooksStoreProps: { exportCheck: mockExportCheck, handleExportDSL: mockHandleExportDSL },
})
expect(result.current.exportCheck).toBe(mockExportCheck)
expect(result.current.handleExportDSL).toBe(mockHandleExportDSL)
})
})
describe('useWorkflowRun', () => {
it('should return all run-related handlers from hooksStore', () => {
const mocks = {
handleBackupDraft: vi.fn(),
handleLoadBackupDraft: vi.fn(),
handleRestoreFromPublishedWorkflow: vi.fn(),
handleRun: vi.fn(),
handleStopRun: vi.fn(),
}
const { result } = renderWorkflowHook(() => useWorkflowRun(), {
hooksStoreProps: mocks,
})
expect(result.current.handleBackupDraft).toBe(mocks.handleBackupDraft)
expect(result.current.handleLoadBackupDraft).toBe(mocks.handleLoadBackupDraft)
expect(result.current.handleRestoreFromPublishedWorkflow).toBe(mocks.handleRestoreFromPublishedWorkflow)
expect(result.current.handleRun).toBe(mocks.handleRun)
expect(result.current.handleStopRun).toBe(mocks.handleStopRun)
})
})
describe('useWorkflowStartRun', () => {
it('should return all start-run handlers from hooksStore', () => {
const mocks = {
handleStartWorkflowRun: vi.fn(),
handleWorkflowStartRunInWorkflow: vi.fn(),
handleWorkflowStartRunInChatflow: vi.fn(),
handleWorkflowTriggerScheduleRunInWorkflow: vi.fn(),
handleWorkflowTriggerWebhookRunInWorkflow: vi.fn(),
handleWorkflowTriggerPluginRunInWorkflow: vi.fn(),
handleWorkflowRunAllTriggersInWorkflow: vi.fn(),
}
const { result } = renderWorkflowHook(() => useWorkflowStartRun(), {
hooksStoreProps: mocks,
})
expect(result.current.handleStartWorkflowRun).toBe(mocks.handleStartWorkflowRun)
expect(result.current.handleWorkflowStartRunInWorkflow).toBe(mocks.handleWorkflowStartRunInWorkflow)
expect(result.current.handleWorkflowStartRunInChatflow).toBe(mocks.handleWorkflowStartRunInChatflow)
expect(result.current.handleWorkflowTriggerScheduleRunInWorkflow).toBe(mocks.handleWorkflowTriggerScheduleRunInWorkflow)
expect(result.current.handleWorkflowTriggerWebhookRunInWorkflow).toBe(mocks.handleWorkflowTriggerWebhookRunInWorkflow)
expect(result.current.handleWorkflowTriggerPluginRunInWorkflow).toBe(mocks.handleWorkflowTriggerPluginRunInWorkflow)
expect(result.current.handleWorkflowRunAllTriggersInWorkflow).toBe(mocks.handleWorkflowRunAllTriggersInWorkflow)
})
})
describe('useWorkflowRefreshDraft', () => {
it('should return handleRefreshWorkflowDraft from hooksStore', () => {
const mockRefresh = vi.fn()
const { result } = renderWorkflowHook(() => useWorkflowRefreshDraft(), {
hooksStoreProps: { handleRefreshWorkflowDraft: mockRefresh },
})
expect(result.current.handleRefreshWorkflowDraft).toBe(mockRefresh)
})
})

View File

@ -0,0 +1,99 @@
import type { WorkflowRunningData } from '../../types'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../types'
import { useNodeDataUpdate } from '../use-node-data-update'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
describe('useNodeDataUpdate', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Node 1', value: 'original' } },
{ id: 'node-2', position: { x: 300, y: 0 }, data: { title: 'Node 2' } },
]
})
describe('handleNodeDataUpdate', () => {
it('should merge data into the target node and call setNodes', () => {
const { result } = renderWorkflowHook(() => useNodeDataUpdate(), {
hooksStoreProps: {},
})
result.current.handleNodeDataUpdate({
id: 'node-1',
data: { value: 'updated', extra: true },
})
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes.find((n: { id: string }) => n.id === 'node-1').data).toEqual({
title: 'Node 1',
value: 'updated',
extra: true,
})
expect(updatedNodes.find((n: { id: string }) => n.id === 'node-2').data).toEqual({
title: 'Node 2',
})
})
})
describe('handleNodeDataUpdateWithSyncDraft', () => {
it('should update node data and trigger debounced sync draft', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const { result, store } = renderWorkflowHook(() => useNodeDataUpdate(), {
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
})
result.current.handleNodeDataUpdateWithSyncDraft({
id: 'node-1',
data: { value: 'synced' },
})
expect(rfState.setNodes).toHaveBeenCalledOnce()
store.getState().flushPendingSync()
expect(mockDoSync).toHaveBeenCalledOnce()
})
it('should call doSyncWorkflowDraft directly when sync=true', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const callback = { onSuccess: vi.fn() }
const { result } = renderWorkflowHook(() => useNodeDataUpdate(), {
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
})
result.current.handleNodeDataUpdateWithSyncDraft(
{ id: 'node-1', data: { value: 'synced' } },
{ sync: true, notRefreshWhenSyncError: true, callback },
)
expect(mockDoSync).toHaveBeenCalledWith(true, callback)
})
it('should do nothing when nodes are read-only', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const { result } = renderWorkflowHook(() => useNodeDataUpdate(), {
initialStoreState: {
workflowRunningData: {
result: { status: WorkflowRunningStatus.Running },
} as WorkflowRunningData,
},
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
})
result.current.handleNodeDataUpdateWithSyncDraft({
id: 'node-1',
data: { value: 'should-not-update' },
})
expect(rfState.setNodes).not.toHaveBeenCalled()
expect(mockDoSync).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,79 @@
import type { WorkflowRunningData } from '../../types'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../types'
import { useNodesSyncDraft } from '../use-nodes-sync-draft'
describe('useNodesSyncDraft', () => {
it('should return doSyncWorkflowDraft, handleSyncWorkflowDraft, and syncWorkflowDraftWhenPageClose', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const mockSyncClose = vi.fn()
const { result } = renderWorkflowHook(() => useNodesSyncDraft(), {
hooksStoreProps: {
doSyncWorkflowDraft: mockDoSync,
syncWorkflowDraftWhenPageClose: mockSyncClose,
},
})
expect(result.current.doSyncWorkflowDraft).toBe(mockDoSync)
expect(result.current.syncWorkflowDraftWhenPageClose).toBe(mockSyncClose)
expect(typeof result.current.handleSyncWorkflowDraft).toBe('function')
})
it('should call doSyncWorkflowDraft synchronously when sync=true', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const { result } = renderWorkflowHook(() => useNodesSyncDraft(), {
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
})
const callback = { onSuccess: vi.fn() }
result.current.handleSyncWorkflowDraft(true, false, callback)
expect(mockDoSync).toHaveBeenCalledWith(false, callback)
})
it('should use debounced path when sync is falsy, then flush triggers doSync', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const { result, store } = renderWorkflowHook(() => useNodesSyncDraft(), {
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
})
result.current.handleSyncWorkflowDraft()
expect(mockDoSync).not.toHaveBeenCalled()
store.getState().flushPendingSync()
expect(mockDoSync).toHaveBeenCalledOnce()
})
it('should do nothing when nodes are read-only (workflow running)', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const { result } = renderWorkflowHook(() => useNodesSyncDraft(), {
initialStoreState: {
workflowRunningData: {
result: { status: WorkflowRunningStatus.Running },
} as WorkflowRunningData,
},
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
})
result.current.handleSyncWorkflowDraft(true)
expect(mockDoSync).not.toHaveBeenCalled()
})
it('should pass notRefreshWhenSyncError to doSyncWorkflowDraft', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const { result } = renderWorkflowHook(() => useNodesSyncDraft(), {
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
})
result.current.handleSyncWorkflowDraft(true, true)
expect(mockDoSync).toHaveBeenCalledWith(true, undefined)
})
})

View File

@ -0,0 +1,78 @@
import type * as React from 'react'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { usePanelInteractions } from '../use-panel-interactions'
describe('usePanelInteractions', () => {
let container: HTMLDivElement
beforeEach(() => {
container = document.createElement('div')
container.id = 'workflow-container'
container.getBoundingClientRect = vi.fn().mockReturnValue({
x: 100,
y: 50,
width: 800,
height: 600,
top: 50,
right: 900,
bottom: 650,
left: 100,
})
document.body.appendChild(container)
})
afterEach(() => {
container.remove()
})
it('handlePaneContextMenu should set panelMenu with computed coordinates when container exists', () => {
const { result, store } = renderWorkflowHook(() => usePanelInteractions())
const preventDefault = vi.fn()
result.current.handlePaneContextMenu({
preventDefault,
clientX: 350,
clientY: 250,
} as unknown as React.MouseEvent)
expect(preventDefault).toHaveBeenCalled()
expect(store.getState().panelMenu).toEqual({
top: 200,
left: 250,
})
})
it('handlePaneContextMenu should throw when container does not exist', () => {
container.remove()
const { result } = renderWorkflowHook(() => usePanelInteractions())
expect(() => {
result.current.handlePaneContextMenu({
preventDefault: vi.fn(),
clientX: 350,
clientY: 250,
} as unknown as React.MouseEvent)
}).toThrow()
})
it('handlePaneContextmenuCancel should clear panelMenu', () => {
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
initialStoreState: { panelMenu: { top: 10, left: 20 } },
})
result.current.handlePaneContextmenuCancel()
expect(store.getState().panelMenu).toBeUndefined()
})
it('handleNodeContextmenuCancel should clear nodeMenu', () => {
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
initialStoreState: { nodeMenu: { top: 10, left: 20, nodeId: 'n1' } },
})
result.current.handleNodeContextmenuCancel()
expect(store.getState().nodeMenu).toBeUndefined()
})
})

View File

@ -0,0 +1,190 @@
import type * as React from 'react'
import type { Node, OnSelectionChangeParams } from 'reactflow'
import type { MockEdge, MockNode } from '../../__tests__/reactflow-mock-state'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { useSelectionInteractions } from '../use-selection-interactions'
const rfStoreExtra = vi.hoisted(() => ({
userSelectionRect: null as { x: number, y: number, width: number, height: number } | null,
userSelectionActive: false,
resetSelectedElements: vi.fn(),
setState: vi.fn(),
}))
vi.mock('reactflow', async () => {
const mod = await import('../../__tests__/reactflow-mock-state')
const base = mod.createReactFlowModuleMock()
return {
...base,
useStoreApi: vi.fn(() => ({
getState: () => ({
getNodes: () => mod.rfState.nodes,
setNodes: mod.rfState.setNodes,
edges: mod.rfState.edges,
setEdges: mod.rfState.setEdges,
transform: mod.rfState.transform,
userSelectionRect: rfStoreExtra.userSelectionRect,
userSelectionActive: rfStoreExtra.userSelectionActive,
resetSelectedElements: rfStoreExtra.resetSelectedElements,
}),
setState: rfStoreExtra.setState,
subscribe: vi.fn().mockReturnValue(vi.fn()),
})),
}
})
describe('useSelectionInteractions', () => {
let container: HTMLDivElement
beforeEach(() => {
resetReactFlowMockState()
rfStoreExtra.userSelectionRect = null
rfStoreExtra.userSelectionActive = false
rfStoreExtra.resetSelectedElements = vi.fn()
rfStoreExtra.setState.mockReset()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _isBundled: true } },
{ id: 'n2', position: { x: 100, y: 100 }, data: { _isBundled: true } },
{ id: 'n3', position: { x: 200, y: 200 }, data: {} },
]
rfState.edges = [
{ id: 'e1', source: 'n1', target: 'n2', data: { _isBundled: true } },
{ id: 'e2', source: 'n2', target: 'n3', data: {} },
]
container = document.createElement('div')
container.id = 'workflow-container'
container.getBoundingClientRect = vi.fn().mockReturnValue({
x: 100,
y: 50,
width: 800,
height: 600,
top: 50,
right: 900,
bottom: 650,
left: 100,
})
document.body.appendChild(container)
})
afterEach(() => {
container.remove()
})
it('handleSelectionStart should clear _isBundled from all nodes and edges', () => {
const { result } = renderWorkflowHook(() => useSelectionInteractions())
result.current.handleSelectionStart()
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
expect(updatedNodes.every(n => !n.data._isBundled)).toBe(true)
const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
expect(updatedEdges.every(e => !e.data._isBundled)).toBe(true)
})
it('handleSelectionChange should mark selected nodes as bundled', () => {
rfStoreExtra.userSelectionRect = { x: 0, y: 0, width: 100, height: 100 }
const { result } = renderWorkflowHook(() => useSelectionInteractions())
result.current.handleSelectionChange({
nodes: [{ id: 'n1' }, { id: 'n3' }],
edges: [],
} as unknown as OnSelectionChangeParams)
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
expect(updatedNodes.find(n => n.id === 'n1')!.data._isBundled).toBe(true)
expect(updatedNodes.find(n => n.id === 'n2')!.data._isBundled).toBe(false)
expect(updatedNodes.find(n => n.id === 'n3')!.data._isBundled).toBe(true)
})
it('handleSelectionChange should mark selected edges', () => {
rfStoreExtra.userSelectionRect = { x: 0, y: 0, width: 100, height: 100 }
const { result } = renderWorkflowHook(() => useSelectionInteractions())
result.current.handleSelectionChange({
nodes: [],
edges: [{ id: 'e1' }],
} as unknown as OnSelectionChangeParams)
const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
expect(updatedEdges.find(e => e.id === 'e1')!.data._isBundled).toBe(true)
expect(updatedEdges.find(e => e.id === 'e2')!.data._isBundled).toBe(false)
})
it('handleSelectionDrag should sync node positions', () => {
const { result, store } = renderWorkflowHook(() => useSelectionInteractions())
const draggedNodes = [
{ id: 'n1', position: { x: 50, y: 60 }, data: {} },
] as unknown as Node[]
result.current.handleSelectionDrag({} as unknown as React.MouseEvent, draggedNodes)
expect(store.getState().nodeAnimation).toBe(false)
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
expect(updatedNodes.find(n => n.id === 'n1')!.position).toEqual({ x: 50, y: 60 })
expect(updatedNodes.find(n => n.id === 'n2')!.position).toEqual({ x: 100, y: 100 })
})
it('handleSelectionCancel should clear all selection state', () => {
const { result } = renderWorkflowHook(() => useSelectionInteractions())
result.current.handleSelectionCancel()
expect(rfStoreExtra.setState).toHaveBeenCalledWith({
userSelectionRect: null,
userSelectionActive: true,
})
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
expect(updatedNodes.every(n => !n.data._isBundled)).toBe(true)
const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
expect(updatedEdges.every(e => !e.data._isBundled)).toBe(true)
})
it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => {
const { result, store } = renderWorkflowHook(() => useSelectionInteractions())
const wrongTarget = document.createElement('div')
wrongTarget.classList.add('some-other-class')
result.current.handleSelectionContextMenu({
target: wrongTarget,
preventDefault: vi.fn(),
clientX: 300,
clientY: 200,
} as unknown as React.MouseEvent)
expect(store.getState().selectionMenu).toBeUndefined()
const correctTarget = document.createElement('div')
correctTarget.classList.add('react-flow__nodesselection-rect')
result.current.handleSelectionContextMenu({
target: correctTarget,
preventDefault: vi.fn(),
clientX: 300,
clientY: 200,
} as unknown as React.MouseEvent)
expect(store.getState().selectionMenu).toEqual({
top: 150,
left: 200,
})
})
it('handleSelectionContextmenuCancel should clear selectionMenu', () => {
const { result, store } = renderWorkflowHook(() => useSelectionInteractions(), {
initialStoreState: { selectionMenu: { top: 50, left: 60 } },
})
result.current.handleSelectionContextmenuCancel()
expect(store.getState().selectionMenu).toBeUndefined()
})
})

View File

@ -0,0 +1,94 @@
import { act, renderHook } from '@testing-library/react'
import { useSerialAsyncCallback } from '../use-serial-async-callback'
describe('useSerialAsyncCallback', () => {
it('should execute a synchronous function and return its result', async () => {
const fn = vi.fn((..._args: number[]) => 42)
const { result } = renderHook(() => useSerialAsyncCallback(fn))
const value = await act(() => result.current(1, 2))
expect(value).toBe(42)
expect(fn).toHaveBeenCalledWith(1, 2)
})
it('should execute an async function and return its result', async () => {
const fn = vi.fn(async (x: number) => x * 2)
const { result } = renderHook(() => useSerialAsyncCallback(fn))
const value = await act(() => result.current(5))
expect(value).toBe(10)
})
it('should serialize concurrent calls sequentially', async () => {
const order: number[] = []
const fn = vi.fn(async (id: number, delay: number) => {
await new Promise(resolve => setTimeout(resolve, delay))
order.push(id)
return id
})
const { result } = renderHook(() => useSerialAsyncCallback(fn))
let r1: number | undefined
let r2: number | undefined
let r3: number | undefined
await act(async () => {
const p1 = result.current(1, 30)
const p2 = result.current(2, 10)
const p3 = result.current(3, 5)
r1 = await p1
r2 = await p2
r3 = await p3
})
expect(order).toEqual([1, 2, 3])
expect(r1).toBe(1)
expect(r2).toBe(2)
expect(r3).toBe(3)
})
it('should skip execution when shouldSkip returns true', async () => {
const fn = vi.fn(async () => 'executed')
const shouldSkip = vi.fn(() => true)
const { result } = renderHook(() => useSerialAsyncCallback(fn, shouldSkip))
const value = await act(() => result.current())
expect(value).toBeUndefined()
expect(fn).not.toHaveBeenCalled()
})
it('should execute when shouldSkip returns false', async () => {
const fn = vi.fn(async () => 'executed')
const shouldSkip = vi.fn(() => false)
const { result } = renderHook(() => useSerialAsyncCallback(fn, shouldSkip))
const value = await act(() => result.current())
expect(value).toBe('executed')
expect(fn).toHaveBeenCalledOnce()
})
it('should continue queuing after a previous call rejects', async () => {
let callCount = 0
const fn = vi.fn(async () => {
callCount++
if (callCount === 1)
throw new Error('fail')
return 'ok'
})
const { result } = renderHook(() => useSerialAsyncCallback(fn))
await act(async () => {
await result.current().catch(() => {})
const value = await result.current()
expect(value).toBe('ok')
})
expect(fn).toHaveBeenCalledTimes(2)
})
})

View File

@ -0,0 +1,171 @@
import { CollectionType } from '@/app/components/tools/types'
import { resetReactFlowMockState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import { useGetToolIcon, useToolIcon } from '../use-tool-icon'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
vi.mock('@/service/use-tools', async () =>
(await import('../../__tests__/service-mock-factory')).createToolServiceMock({
buildInTools: [{ id: 'builtin-1', name: 'builtin', icon: '/builtin.svg', icon_dark: '/builtin-dark.svg', plugin_id: 'p1' }],
customTools: [{ id: 'custom-1', name: 'custom', icon: '/custom.svg', plugin_id: 'p2' }],
}))
vi.mock('@/service/use-triggers', async () =>
(await import('../../__tests__/service-mock-factory')).createTriggerServiceMock({
triggerPlugins: [{ id: 'trigger-1', icon: '/trigger.svg', icon_dark: '/trigger-dark.svg' }],
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
}))
vi.mock('@/utils', () => ({
canFindTool: (id: string, target: string) => id === target,
}))
const baseNodeData = { title: '', desc: '' }
describe('useToolIcon', () => {
beforeEach(() => {
resetReactFlowMockState()
})
it('should return empty string when no data', () => {
const { result } = renderWorkflowHook(() => useToolIcon(undefined))
expect(result.current).toBe('')
})
it('should find icon for TriggerPlugin node', () => {
const data = {
...baseNodeData,
type: BlockEnum.TriggerPlugin,
plugin_id: 'trigger-1',
provider_id: 'trigger-1',
provider_name: 'trigger-1',
}
const { result } = renderWorkflowHook(() => useToolIcon(data))
expect(result.current).toBe('/trigger.svg')
})
it('should find icon for Tool node (builtIn)', () => {
const data = {
...baseNodeData,
type: BlockEnum.Tool,
provider_type: CollectionType.builtIn,
provider_id: 'builtin-1',
plugin_id: 'p1',
provider_name: 'builtin',
}
const { result } = renderWorkflowHook(() => useToolIcon(data))
expect(result.current).toBe('/builtin.svg')
})
it('should find icon for Tool node (custom)', () => {
const data = {
...baseNodeData,
type: BlockEnum.Tool,
provider_type: CollectionType.custom,
provider_id: 'custom-1',
plugin_id: 'p2',
provider_name: 'custom',
}
const { result } = renderWorkflowHook(() => useToolIcon(data))
expect(result.current).toBe('/custom.svg')
})
it('should fallback to provider_icon when no collection match', () => {
const data = {
...baseNodeData,
type: BlockEnum.Tool,
provider_type: CollectionType.builtIn,
provider_id: 'unknown-provider',
plugin_id: 'unknown-plugin',
provider_name: 'unknown',
provider_icon: '/fallback.svg',
}
const { result } = renderWorkflowHook(() => useToolIcon(data))
expect(result.current).toBe('/fallback.svg')
})
it('should return empty string for unmatched DataSource node', () => {
const data = {
...baseNodeData,
type: BlockEnum.DataSource,
plugin_id: 'unknown-ds',
}
const { result } = renderWorkflowHook(() => useToolIcon(data))
expect(result.current).toBe('')
})
it('should return empty string for unrecognized node type', () => {
const data = {
...baseNodeData,
type: BlockEnum.LLM,
}
const { result } = renderWorkflowHook(() => useToolIcon(data))
expect(result.current).toBe('')
})
})
describe('useGetToolIcon', () => {
beforeEach(() => {
resetReactFlowMockState()
})
it('should return a function', () => {
const { result } = renderWorkflowHook(() => useGetToolIcon())
expect(typeof result.current).toBe('function')
})
it('should find icon for TriggerPlugin node via returned function', () => {
const { result } = renderWorkflowHook(() => useGetToolIcon())
const data = {
...baseNodeData,
type: BlockEnum.TriggerPlugin,
plugin_id: 'trigger-1',
provider_id: 'trigger-1',
provider_name: 'trigger-1',
}
const icon = result.current(data)
expect(icon).toBe('/trigger.svg')
})
it('should find icon for Tool node via returned function', () => {
const { result } = renderWorkflowHook(() => useGetToolIcon())
const data = {
...baseNodeData,
type: BlockEnum.Tool,
provider_type: CollectionType.builtIn,
provider_id: 'builtin-1',
plugin_id: 'p1',
provider_name: 'builtin',
}
const icon = result.current(data)
expect(icon).toBe('/builtin.svg')
})
it('should return undefined for unmatched node type', () => {
const { result } = renderWorkflowHook(() => useGetToolIcon())
const data = {
...baseNodeData,
type: BlockEnum.LLM,
}
const icon = result.current(data)
expect(icon).toBeUndefined()
})
})

View File

@ -0,0 +1,130 @@
import { renderHook } from '@testing-library/react'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { NodeRunningStatus } from '../../types'
import { useEdgesInteractionsWithoutSync } from '../use-edges-interactions-without-sync'
import { useNodesInteractionsWithoutSync } from '../use-nodes-interactions-without-sync'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
describe('useEdgesInteractionsWithoutSync', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.edges = [
{ id: 'e1', source: 'a', target: 'b', data: { _sourceRunningStatus: 'running', _targetRunningStatus: 'running', _waitingRun: true } },
{ id: 'e2', source: 'b', target: 'c', data: { _sourceRunningStatus: 'succeeded', _targetRunningStatus: undefined, _waitingRun: false } },
]
})
it('should clear running status and waitingRun on all edges', () => {
const { result } = renderHook(() => useEdgesInteractionsWithoutSync())
result.current.handleEdgeCancelRunningStatus()
expect(rfState.setEdges).toHaveBeenCalledOnce()
const updated = rfState.setEdges.mock.calls[0][0]
for (const edge of updated) {
expect(edge.data._sourceRunningStatus).toBeUndefined()
expect(edge.data._targetRunningStatus).toBeUndefined()
expect(edge.data._waitingRun).toBe(false)
}
})
it('should not mutate original edges', () => {
const originalData = { ...rfState.edges[0].data }
const { result } = renderHook(() => useEdgesInteractionsWithoutSync())
result.current.handleEdgeCancelRunningStatus()
expect(rfState.edges[0].data._sourceRunningStatus).toBe(originalData._sourceRunningStatus)
})
})
describe('useNodesInteractionsWithoutSync', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } },
{ id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } },
{ id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } },
]
})
describe('handleNodeCancelRunningStatus', () => {
it('should clear _runningStatus and _waitingRun on all nodes', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
result.current.handleNodeCancelRunningStatus()
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updated = rfState.setNodes.mock.calls[0][0]
for (const node of updated) {
expect(node.data._runningStatus).toBeUndefined()
expect(node.data._waitingRun).toBe(false)
}
})
})
describe('handleCancelAllNodeSuccessStatus', () => {
it('should clear _runningStatus only for Succeeded nodes', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
result.current.handleCancelAllNodeSuccessStatus()
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updated = rfState.setNodes.mock.calls[0][0]
const n1 = updated.find((n: { id: string }) => n.id === 'n1')
const n2 = updated.find((n: { id: string }) => n.id === 'n2')
const n3 = updated.find((n: { id: string }) => n.id === 'n3')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n2.data._runningStatus).toBeUndefined()
expect(n3.data._runningStatus).toBe(NodeRunningStatus.Failed)
})
it('should not modify _waitingRun', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
result.current.handleCancelAllNodeSuccessStatus()
const updated = rfState.setNodes.mock.calls[0][0]
expect(updated.find((n: { id: string }) => n.id === 'n1').data._waitingRun).toBe(true)
expect(updated.find((n: { id: string }) => n.id === 'n3').data._waitingRun).toBe(true)
})
})
describe('handleCancelNodeSuccessStatus', () => {
it('should clear _runningStatus and _waitingRun for the specified Succeeded node', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
result.current.handleCancelNodeSuccessStatus('n2')
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updated = rfState.setNodes.mock.calls[0][0]
const n2 = updated.find((n: { id: string }) => n.id === 'n2')
expect(n2.data._runningStatus).toBeUndefined()
expect(n2.data._waitingRun).toBe(false)
})
it('should not modify nodes that are not Succeeded', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
result.current.handleCancelNodeSuccessStatus('n1')
const updated = rfState.setNodes.mock.calls[0][0]
const n1 = updated.find((n: { id: string }) => n.id === 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._waitingRun).toBe(true)
})
it('should not modify other nodes', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
result.current.handleCancelNodeSuccessStatus('n2')
const updated = rfState.setNodes.mock.calls[0][0]
const n1 = updated.find((n: { id: string }) => n.id === 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
})
})
})

View File

@ -0,0 +1,47 @@
import type { HistoryWorkflowData } from '../../types'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { useWorkflowMode } from '../use-workflow-mode'
describe('useWorkflowMode', () => {
it('should return normal mode when no history data and not restoring', () => {
const { result } = renderWorkflowHook(() => useWorkflowMode())
expect(result.current.normal).toBe(true)
expect(result.current.restoring).toBe(false)
expect(result.current.viewHistory).toBe(false)
})
it('should return restoring mode when isRestoring is true', () => {
const { result } = renderWorkflowHook(() => useWorkflowMode(), {
initialStoreState: { isRestoring: true },
})
expect(result.current.normal).toBe(false)
expect(result.current.restoring).toBe(true)
expect(result.current.viewHistory).toBe(false)
})
it('should return viewHistory mode when historyWorkflowData exists', () => {
const { result } = renderWorkflowHook(() => useWorkflowMode(), {
initialStoreState: {
historyWorkflowData: { id: 'v1', status: 'succeeded' } as HistoryWorkflowData,
},
})
expect(result.current.normal).toBe(false)
expect(result.current.restoring).toBe(false)
expect(result.current.viewHistory).toBe(true)
})
it('should prioritize restoring over viewHistory when both are set', () => {
const { result } = renderWorkflowHook(() => useWorkflowMode(), {
initialStoreState: {
isRestoring: true,
historyWorkflowData: { id: 'v1', status: 'succeeded' } as HistoryWorkflowData,
},
})
expect(result.current.restoring).toBe(true)
expect(result.current.normal).toBe(false)
})
})

View File

@ -0,0 +1,242 @@
import type {
AgentLogResponse,
HumanInputFormFilledResponse,
HumanInputFormTimeoutResponse,
TextChunkResponse,
TextReplaceResponse,
WorkflowFinishedResponse,
} from '@/types/workflow'
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../types'
import { useWorkflowAgentLog } from '../use-workflow-run-event/use-workflow-agent-log'
import { useWorkflowFailed } from '../use-workflow-run-event/use-workflow-failed'
import { useWorkflowFinished } from '../use-workflow-run-event/use-workflow-finished'
import { useWorkflowNodeHumanInputFormFilled } from '../use-workflow-run-event/use-workflow-node-human-input-form-filled'
import { useWorkflowNodeHumanInputFormTimeout } from '../use-workflow-run-event/use-workflow-node-human-input-form-timeout'
import { useWorkflowPaused } from '../use-workflow-run-event/use-workflow-paused'
import { useWorkflowTextChunk } from '../use-workflow-run-event/use-workflow-text-chunk'
import { useWorkflowTextReplace } from '../use-workflow-run-event/use-workflow-text-replace'
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getFilesInLogs: vi.fn(() => []),
}))
describe('useWorkflowFailed', () => {
it('should set status to Failed', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowFailed(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowFailed()
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Failed)
})
})
describe('useWorkflowPaused', () => {
it('should set status to Paused', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowPaused(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowPaused()
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Paused)
})
})
describe('useWorkflowTextChunk', () => {
it('should append text and activate result tab', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowTextChunk(), {
initialStoreState: {
workflowRunningData: baseRunningData({ resultText: 'Hello' }),
},
})
result.current.handleWorkflowTextChunk({ data: { text: ' World' } } as TextChunkResponse)
const state = store.getState().workflowRunningData!
expect(state.resultText).toBe('Hello World')
expect(state.resultTabActive).toBe(true)
})
})
describe('useWorkflowTextReplace', () => {
it('should replace resultText', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowTextReplace(), {
initialStoreState: {
workflowRunningData: baseRunningData({ resultText: 'old text' }),
},
})
result.current.handleWorkflowTextReplace({ data: { text: 'new text' } } as TextReplaceResponse)
expect(store.getState().workflowRunningData!.resultText).toBe('new text')
})
})
describe('useWorkflowFinished', () => {
it('should merge data into result and activate result tab for single string output', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowFinished({
data: { status: 'succeeded', outputs: { answer: 'hello' } },
} as WorkflowFinishedResponse)
const state = store.getState().workflowRunningData!
expect(state.result.status).toBe('succeeded')
expect(state.resultTabActive).toBe(true)
expect(state.resultText).toBe('hello')
})
it('should not activate result tab for multi-key outputs', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowFinished({
data: { status: 'succeeded', outputs: { a: 'hello', b: 'world' } },
} as WorkflowFinishedResponse)
expect(store.getState().workflowRunningData!.resultTabActive).toBeFalsy()
})
})
describe('useWorkflowAgentLog', () => {
it('should create agent_log array when execution_metadata has no agent_log', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', execution_metadata: {} }],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm1' },
} as AgentLogResponse)
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.execution_metadata!.agent_log).toHaveLength(1)
expect(trace.execution_metadata!.agent_log![0].message_id).toBe('m1')
})
it('should append to existing agent_log', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{
node_id: 'n1',
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'log1' }] },
}],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm2' },
} as AgentLogResponse)
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(2)
})
it('should update existing log entry by message_id', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{
node_id: 'n1',
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'old' }] },
}],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm1', text: 'new' },
} as unknown as AgentLogResponse)
const log = store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log!
expect(log).toHaveLength(1)
expect((log[0] as unknown as { text: string }).text).toBe('new')
})
it('should create execution_metadata when it does not exist', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1' }],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm1' },
} as AgentLogResponse)
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(1)
})
})
describe('useWorkflowNodeHumanInputFormFilled', () => {
it('should remove form from humanInputFormDataList and add to humanInputFilledFormDataList', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), {
initialStoreState: {
workflowRunningData: baseRunningData({
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputFormFilled({
data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' },
} as HumanInputFormFilledResponse)
const state = store.getState().workflowRunningData!
expect(state.humanInputFormDataList).toHaveLength(0)
expect(state.humanInputFilledFormDataList).toHaveLength(1)
expect(state.humanInputFilledFormDataList![0].node_id).toBe('n1')
})
it('should create humanInputFilledFormDataList when it does not exist', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), {
initialStoreState: {
workflowRunningData: baseRunningData({
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputFormFilled({
data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' },
} as HumanInputFormFilledResponse)
expect(store.getState().workflowRunningData!.humanInputFilledFormDataList).toBeDefined()
})
})
describe('useWorkflowNodeHumanInputFormTimeout', () => {
it('should set expiration_time on the matching form', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormTimeout(), {
initialStoreState: {
workflowRunningData: baseRunningData({
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '', expiration_time: 0 },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputFormTimeout({
data: { node_id: 'n1', node_title: 'Node 1', expiration_time: 1000 },
} as HumanInputFormTimeoutResponse)
expect(store.getState().workflowRunningData!.humanInputFormDataList![0].expiration_time).toBe(1000)
})
})

View File

@ -0,0 +1,269 @@
import type { WorkflowRunningData } from '../../types'
import type {
IterationFinishedResponse,
IterationNextResponse,
LoopFinishedResponse,
LoopNextResponse,
NodeFinishedResponse,
WorkflowStartedResponse,
} from '@/types/workflow'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { DEFAULT_ITER_TIMES } from '../../constants'
import { NodeRunningStatus, WorkflowRunningStatus } from '../../types'
import { useWorkflowNodeFinished } from '../use-workflow-run-event/use-workflow-node-finished'
import { useWorkflowNodeIterationFinished } from '../use-workflow-run-event/use-workflow-node-iteration-finished'
import { useWorkflowNodeIterationNext } from '../use-workflow-run-event/use-workflow-node-iteration-next'
import { useWorkflowNodeLoopFinished } from '../use-workflow-run-event/use-workflow-node-loop-finished'
import { useWorkflowNodeLoopNext } from '../use-workflow-run-event/use-workflow-node-loop-next'
import { useWorkflowNodeRetry } from '../use-workflow-run-event/use-workflow-node-retry'
import { useWorkflowStarted } from '../use-workflow-run-event/use-workflow-started'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
describe('useWorkflowStarted', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _waitingRun: false } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should initialize workflow running data and reset nodes/edges', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowStarted({
task_id: 'task-2',
data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 },
} as WorkflowStartedResponse)
const state = store.getState().workflowRunningData!
expect(state.task_id).toBe('task-2')
expect(state.result.status).toBe(WorkflowRunningStatus.Running)
expect(state.resultText).toBe('')
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._waitingRun).toBe(true)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
it('should resume from Paused without resetting nodes/edges', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
result: { status: WorkflowRunningStatus.Paused } as WorkflowRunningData['result'],
}),
},
})
result.current.handleWorkflowStarted({
task_id: 'task-2',
data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 },
} as WorkflowStartedResponse)
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Running)
expect(rfState.setNodes).not.toHaveBeenCalled()
expect(rfState.setEdges).not.toHaveBeenCalled()
})
})
describe('useWorkflowNodeFinished', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should update tracing and node running status', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeFinished(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
result.current.handleWorkflowNodeFinished({
data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as NodeFinishedResponse)
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
it('should set _runningBranchId for IfElse node', () => {
const { result } = renderWorkflowHook(() => useWorkflowNodeFinished(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
result.current.handleWorkflowNodeFinished({
data: {
id: 'trace-1',
node_id: 'n1',
node_type: 'if-else',
status: NodeRunningStatus.Succeeded,
outputs: { selected_case_id: 'branch-a' },
},
} as unknown as NodeFinishedResponse)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._runningBranchId).toBe('branch-a')
})
})
describe('useWorkflowNodeRetry', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
]
})
it('should push retry data to tracing and update _retryIndex', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeRetry(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowNodeRetry({
data: { node_id: 'n1', retry_index: 2 },
} as NodeFinishedResponse)
expect(store.getState().workflowRunningData!.tracing).toHaveLength(1)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._retryIndex).toBe(2)
})
})
describe('useWorkflowNodeIterationNext', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
]
})
it('should set _iterationIndex and increment iterTimes', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationNext(), {
initialStoreState: {
workflowRunningData: baseRunningData(),
iterTimes: 3,
},
})
result.current.handleWorkflowNodeIterationNext({
data: { node_id: 'n1' },
} as IterationNextResponse)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._iterationIndex).toBe(3)
expect(store.getState().iterTimes).toBe(4)
})
})
describe('useWorkflowNodeIterationFinished', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should update tracing, reset iterTimes, update node status and edges', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationFinished(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
iterTimes: 10,
},
})
result.current.handleWorkflowNodeIterationFinished({
data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as IterationFinishedResponse)
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
})
describe('useWorkflowNodeLoopNext', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
{ id: 'n2', position: { x: 300, y: 0 }, parentId: 'n1', data: { _waitingRun: false } },
]
})
it('should set _loopIndex and reset child nodes to waiting', () => {
const { result } = renderWorkflowHook(() => useWorkflowNodeLoopNext(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowNodeLoopNext({
data: { node_id: 'n1', index: 5 },
} as LoopNextResponse)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._loopIndex).toBe(5)
expect(updatedNodes[1].data._waitingRun).toBe(true)
expect(updatedNodes[1].data._runningStatus).toBe(NodeRunningStatus.Waiting)
})
})
describe('useWorkflowNodeLoopFinished', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should update tracing, node status and edges', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeLoopFinished(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
result.current.handleWorkflowNodeLoopFinished({
data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as LoopFinishedResponse)
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
})

View File

@ -0,0 +1,244 @@
import type {
HumanInputRequiredResponse,
IterationStartedResponse,
LoopStartedResponse,
NodeStartedResponse,
} from '@/types/workflow'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { DEFAULT_ITER_TIMES } from '../../constants'
import { NodeRunningStatus } from '../../types'
import { useWorkflowNodeHumanInputRequired } from '../use-workflow-run-event/use-workflow-node-human-input-required'
import { useWorkflowNodeIterationStarted } from '../use-workflow-run-event/use-workflow-node-iteration-started'
import { useWorkflowNodeLoopStarted } from '../use-workflow-run-event/use-workflow-node-loop-started'
import { useWorkflowNodeStarted } from '../use-workflow-run-event/use-workflow-node-started'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
function findNodeById(nodes: Array<{ id: string, data: Record<string, unknown> }>, id: string) {
return nodes.find(n => n.id === id)!
}
const containerParams = { clientWidth: 1200, clientHeight: 800 }
describe('useWorkflowNodeStarted', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
{ id: 'n2', position: { x: 400, y: 50 }, width: 200, height: 80, parentId: 'n1', data: { _waitingRun: true } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should push to tracing, set node running, and adjust viewport for root node', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(1)
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
expect(rfState.setViewport).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const n1 = findNodeById(updatedNodes, 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._waitingRun).toBe(false)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
it('should not adjust viewport for child node (has parentId)', () => {
const { result } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n2' } } as NodeStartedResponse,
containerParams,
)
expect(rfState.setViewport).not.toHaveBeenCalled()
})
it('should update existing tracing entry if node_id exists at non-zero index', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [
{ node_id: 'n0', status: NodeRunningStatus.Succeeded },
{ node_id: 'n1', status: NodeRunningStatus.Succeeded },
],
}),
},
})
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(2)
expect(tracing[1].status).toBe(NodeRunningStatus.Running)
})
})
describe('useWorkflowNodeIterationStarted', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should push to tracing, reset iterTimes, set viewport, and update node with _iterationLength', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData(),
iterTimes: 99,
},
})
result.current.handleWorkflowNodeIterationStarted(
{ data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse,
containerParams,
)
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
expect(rfState.setViewport).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const n1 = findNodeById(updatedNodes, 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._iterationLength).toBe(10)
expect(n1.data._waitingRun).toBe(false)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
})
describe('useWorkflowNodeLoopStarted', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should push to tracing, set viewport, and update node with _loopLength', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeLoopStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowNodeLoopStarted(
{ data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse,
containerParams,
)
expect(store.getState().workflowRunningData!.tracing![0].status).toBe(NodeRunningStatus.Running)
expect(rfState.setViewport).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const n1 = findNodeById(updatedNodes, 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._loopLength).toBe(5)
expect(n1.data._waitingRun).toBe(false)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
})
describe('useWorkflowNodeHumanInputRequired', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
{ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
})
it('should create humanInputFormDataList and set tracing/node to Paused', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' },
} as HumanInputRequiredResponse)
const state = store.getState().workflowRunningData!
expect(state.humanInputFormDataList).toHaveLength(1)
expect(state.humanInputFormDataList![0].form_id).toBe('f1')
expect(state.tracing![0].status).toBe(NodeRunningStatus.Paused)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(findNodeById(updatedNodes, 'n1').data._runningStatus).toBe(NodeRunningStatus.Paused)
})
it('should update existing form entry for same node_id', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'old', node_title: 'Node 1', form_content: 'old' },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' },
} as HumanInputRequiredResponse)
const formList = store.getState().workflowRunningData!.humanInputFormDataList!
expect(formList).toHaveLength(1)
expect(formList[0].form_id).toBe('new')
})
it('should append new form entry for different node_id', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n2', status: NodeRunningStatus.Running }],
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' },
} as HumanInputRequiredResponse)
expect(store.getState().workflowRunningData!.humanInputFormDataList).toHaveLength(2)
})
})

View File

@ -0,0 +1,148 @@
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { useWorkflowVariables, useWorkflowVariableType } from '../use-workflow-variables'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
vi.mock('@/service/use-tools', async () =>
(await import('../../__tests__/service-mock-factory')).createToolServiceMock())
const { mockToNodeAvailableVars, mockGetVarType } = vi.hoisted(() => ({
mockToNodeAvailableVars: vi.fn((_args: Record<string, unknown>) => [] as unknown[]),
mockGetVarType: vi.fn((_args: Record<string, unknown>) => 'string' as string),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
toNodeAvailableVars: mockToNodeAvailableVars,
getVarType: mockGetVarType,
}))
vi.mock('../../nodes/_base/components/variable/use-match-schema-type', () => ({
default: () => ({ schemaTypeDefinitions: [] }),
}))
let mockIsChatMode = false
vi.mock('../use-workflow', () => ({
useIsChatMode: () => mockIsChatMode,
}))
describe('useWorkflowVariables', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getNodeAvailableVars', () => {
it('should call toNodeAvailableVars with store data', () => {
const { result } = renderWorkflowHook(() => useWorkflowVariables(), {
initialStoreState: {
conversationVariables: [{ id: 'cv1' }] as never[],
environmentVariables: [{ id: 'ev1' }] as never[],
},
})
result.current.getNodeAvailableVars({
beforeNodes: [],
isChatMode: true,
filterVar: () => true,
})
expect(mockToNodeAvailableVars).toHaveBeenCalledOnce()
const args = mockToNodeAvailableVars.mock.calls[0][0]
expect(args.isChatMode).toBe(true)
expect(args.conversationVariables).toHaveLength(1)
expect(args.environmentVariables).toHaveLength(1)
})
it('should hide env variables when hideEnv is true', () => {
const { result } = renderWorkflowHook(() => useWorkflowVariables(), {
initialStoreState: {
environmentVariables: [{ id: 'ev1' }] as never[],
},
})
result.current.getNodeAvailableVars({
beforeNodes: [],
isChatMode: false,
filterVar: () => true,
hideEnv: true,
})
const args = mockToNodeAvailableVars.mock.calls[0][0]
expect(args.environmentVariables).toEqual([])
})
it('should hide chat variables when not in chat mode', () => {
const { result } = renderWorkflowHook(() => useWorkflowVariables(), {
initialStoreState: {
conversationVariables: [{ id: 'cv1' }] as never[],
},
})
result.current.getNodeAvailableVars({
beforeNodes: [],
isChatMode: false,
filterVar: () => true,
})
const args = mockToNodeAvailableVars.mock.calls[0][0]
expect(args.conversationVariables).toEqual([])
})
})
describe('getCurrentVariableType', () => {
it('should call getVarType with store data and return the result', () => {
mockGetVarType.mockReturnValue('number')
const { result } = renderWorkflowHook(() => useWorkflowVariables())
const type = result.current.getCurrentVariableType({
valueSelector: ['node-1', 'output'],
availableNodes: [],
isChatMode: false,
})
expect(mockGetVarType).toHaveBeenCalledOnce()
expect(type).toBe('number')
})
})
})
describe('useWorkflowVariableType', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
mockIsChatMode = false
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { isInIteration: false } },
{ id: 'n2', position: { x: 300, y: 0 }, data: { isInIteration: true }, parentId: 'iter-1' },
{ id: 'iter-1', position: { x: 0, y: 200 }, data: {} },
]
})
it('should return a function', () => {
const { result } = renderWorkflowHook(() => useWorkflowVariableType())
expect(typeof result.current).toBe('function')
})
it('should call getCurrentVariableType with the correct node', () => {
mockGetVarType.mockReturnValue('string')
const { result } = renderWorkflowHook(() => useWorkflowVariableType())
const type = result.current({ nodeId: 'n1', valueSelector: ['n1', 'output'] })
expect(mockGetVarType).toHaveBeenCalledOnce()
expect(type).toBe('string')
})
it('should pass iterationNode as parentNode when node is in iteration', () => {
mockGetVarType.mockReturnValue('array')
const { result } = renderWorkflowHook(() => useWorkflowVariableType())
result.current({ nodeId: 'n2', valueSelector: ['n2', 'item'] })
const args = mockGetVarType.mock.calls[0][0]
expect(args.parentNode).toBeDefined()
expect((args.parentNode as { id: string }).id).toBe('iter-1')
})
})

View File

@ -0,0 +1,234 @@
import { act, renderHook } from '@testing-library/react'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../types'
import {
useIsChatMode,
useIsNodeInIteration,
useIsNodeInLoop,
useNodesReadOnly,
useWorkflowReadOnly,
} from '../use-workflow'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
let mockAppMode = 'workflow'
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: { mode: string } }) => unknown) => selector({ appDetail: { mode: mockAppMode } }),
}))
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
mockAppMode = 'workflow'
})
// ---------------------------------------------------------------------------
// useIsChatMode
// ---------------------------------------------------------------------------
describe('useIsChatMode', () => {
it('should return true when app mode is advanced-chat', () => {
mockAppMode = 'advanced-chat'
const { result } = renderHook(() => useIsChatMode())
expect(result.current).toBe(true)
})
it('should return false when app mode is workflow', () => {
mockAppMode = 'workflow'
const { result } = renderHook(() => useIsChatMode())
expect(result.current).toBe(false)
})
it('should return false when app mode is chat', () => {
mockAppMode = 'chat'
const { result } = renderHook(() => useIsChatMode())
expect(result.current).toBe(false)
})
it('should return false when app mode is completion', () => {
mockAppMode = 'completion'
const { result } = renderHook(() => useIsChatMode())
expect(result.current).toBe(false)
})
})
// ---------------------------------------------------------------------------
// useWorkflowReadOnly
// ---------------------------------------------------------------------------
describe('useWorkflowReadOnly', () => {
it('should return workflowReadOnly true when status is Running', () => {
const { result } = renderWorkflowHook(() => useWorkflowReadOnly(), {
initialStoreState: {
workflowRunningData: baseRunningData(),
},
})
expect(result.current.workflowReadOnly).toBe(true)
})
it('should return workflowReadOnly false when status is Succeeded', () => {
const { result } = renderWorkflowHook(() => useWorkflowReadOnly(), {
initialStoreState: {
workflowRunningData: baseRunningData({ result: { status: WorkflowRunningStatus.Succeeded } }),
},
})
expect(result.current.workflowReadOnly).toBe(false)
})
it('should return workflowReadOnly false when no running data', () => {
const { result } = renderWorkflowHook(() => useWorkflowReadOnly())
expect(result.current.workflowReadOnly).toBe(false)
})
it('should expose getWorkflowReadOnly that reads from store state', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowReadOnly())
expect(result.current.getWorkflowReadOnly()).toBe(false)
act(() => {
store.setState({
workflowRunningData: baseRunningData({ task_id: 'task-2' }),
})
})
expect(result.current.getWorkflowReadOnly()).toBe(true)
})
})
// ---------------------------------------------------------------------------
// useNodesReadOnly
// ---------------------------------------------------------------------------
describe('useNodesReadOnly', () => {
it('should return true when status is Running', () => {
const { result } = renderWorkflowHook(() => useNodesReadOnly(), {
initialStoreState: {
workflowRunningData: baseRunningData(),
},
})
expect(result.current.nodesReadOnly).toBe(true)
})
it('should return true when status is Paused', () => {
const { result } = renderWorkflowHook(() => useNodesReadOnly(), {
initialStoreState: {
workflowRunningData: baseRunningData({ result: { status: WorkflowRunningStatus.Paused } }),
},
})
expect(result.current.nodesReadOnly).toBe(true)
})
it('should return true when historyWorkflowData is present', () => {
const { result } = renderWorkflowHook(() => useNodesReadOnly(), {
initialStoreState: {
historyWorkflowData: { id: 'run-1', status: 'succeeded' },
},
})
expect(result.current.nodesReadOnly).toBe(true)
})
it('should return true when isRestoring is true', () => {
const { result } = renderWorkflowHook(() => useNodesReadOnly(), {
initialStoreState: { isRestoring: true },
})
expect(result.current.nodesReadOnly).toBe(true)
})
it('should return false when none of the conditions are met', () => {
const { result } = renderWorkflowHook(() => useNodesReadOnly())
expect(result.current.nodesReadOnly).toBe(false)
})
it('should expose getNodesReadOnly that reads from store state', () => {
const { result, store } = renderWorkflowHook(() => useNodesReadOnly())
expect(result.current.getNodesReadOnly()).toBe(false)
act(() => {
store.setState({ isRestoring: true })
})
expect(result.current.getNodesReadOnly()).toBe(true)
})
})
// ---------------------------------------------------------------------------
// useIsNodeInIteration
// ---------------------------------------------------------------------------
describe('useIsNodeInIteration', () => {
beforeEach(() => {
rfState.nodes = [
{ id: 'iter-1', position: { x: 0, y: 0 }, data: { type: 'iteration' } },
{ id: 'child-1', position: { x: 10, y: 0 }, parentId: 'iter-1', data: {} },
{ id: 'grandchild-1', position: { x: 20, y: 0 }, parentId: 'child-1', data: {} },
{ id: 'outside-1', position: { x: 100, y: 0 }, data: {} },
]
})
it('should return true when node is a direct child of the iteration', () => {
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
expect(result.current.isNodeInIteration('child-1')).toBe(true)
})
it('should return false for a grandchild (only checks direct parentId)', () => {
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
expect(result.current.isNodeInIteration('grandchild-1')).toBe(false)
})
it('should return false when node is outside the iteration', () => {
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
expect(result.current.isNodeInIteration('outside-1')).toBe(false)
})
it('should return false when node does not exist', () => {
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
expect(result.current.isNodeInIteration('nonexistent')).toBe(false)
})
it('should return false when iteration id has no children', () => {
const { result } = renderHook(() => useIsNodeInIteration('no-such-iter'))
expect(result.current.isNodeInIteration('child-1')).toBe(false)
})
})
// ---------------------------------------------------------------------------
// useIsNodeInLoop
// ---------------------------------------------------------------------------
describe('useIsNodeInLoop', () => {
beforeEach(() => {
rfState.nodes = [
{ id: 'loop-1', position: { x: 0, y: 0 }, data: { type: 'loop' } },
{ id: 'child-1', position: { x: 10, y: 0 }, parentId: 'loop-1', data: {} },
{ id: 'grandchild-1', position: { x: 20, y: 0 }, parentId: 'child-1', data: {} },
{ id: 'outside-1', position: { x: 100, y: 0 }, data: {} },
]
})
it('should return true when node is a direct child of the loop', () => {
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
expect(result.current.isNodeInLoop('child-1')).toBe(true)
})
it('should return false for a grandchild (only checks direct parentId)', () => {
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
expect(result.current.isNodeInLoop('grandchild-1')).toBe(false)
})
it('should return false when node is outside the loop', () => {
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
expect(result.current.isNodeInLoop('outside-1')).toBe(false)
})
it('should return false when node does not exist', () => {
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
expect(result.current.isNodeInLoop('nonexistent')).toBe(false)
})
it('should return false when loop id has no children', () => {
const { result } = renderHook(() => useIsNodeInLoop('no-such-loop'))
expect(result.current.isNodeInLoop('child-1')).toBe(false)
})
})

View File

@ -7,7 +7,7 @@ import {
// Mock the getMatchedSchemaType dependency
vi.mock('../../_base/components/variable/use-match-schema-type', () => ({
getMatchedSchemaType: (schema: any) => {
getMatchedSchemaType: (schema: Record<string, unknown> | null | undefined) => {
// Return schema_type or schemaType if present
return schema?.schema_type || schema?.schemaType || undefined
},

View File

@ -281,7 +281,7 @@ describe('Form Helpers', () => {
describe('Edge cases', () => {
it('should handle objects with non-string keys', () => {
const input = { [Symbol('test')]: 'value', regular: 'field' } as any
const input = { [Symbol('test')]: 'value', regular: 'field' } as Record<string, unknown>
const result = sanitizeFormValues(input)
expect(result.regular).toBe('field')
@ -299,7 +299,7 @@ describe('Form Helpers', () => {
})
it('should handle circular references in deepSanitizeFormValues gracefully', () => {
const obj: any = { field: 'value' }
const obj: Record<string, unknown> = { field: 'value' }
obj.circular = obj
expect(() => deepSanitizeFormValues(obj)).not.toThrow()

View File

@ -1,9 +1,9 @@
import type { ConversationVariable } from '@/app/components/workflow/types'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import { createWorkflowStore } from '../workflow'
import { createTestWorkflowStore } from '../../__tests__/workflow-test-env'
function createStore() {
return createWorkflowStore({})
return createTestWorkflowStore()
}
describe('Chat Variable Slice', () => {

View File

@ -1,8 +1,8 @@
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { createWorkflowStore } from '../workflow'
import { createTestWorkflowStore } from '../../__tests__/workflow-test-env'
function createStore() {
return createWorkflowStore({})
return createTestWorkflowStore()
}
describe('Env Variable Slice', () => {

View File

@ -1,10 +1,10 @@
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { VarInInspectType } from '@/types/workflow'
import { createWorkflowStore } from '../workflow'
import { createTestWorkflowStore } from '../../__tests__/workflow-test-env'
function createStore() {
return createWorkflowStore({})
return createTestWorkflowStore()
}
function makeVar(overrides: Partial<VarInInspect> = {}): VarInInspect {

View File

@ -1,8 +1,8 @@
import type { VersionHistory } from '@/types/workflow'
import { createWorkflowStore } from '../workflow'
import { createTestWorkflowStore } from '../../__tests__/workflow-test-env'
function createStore() {
return createWorkflowStore({})
return createTestWorkflowStore()
}
describe('Version Slice', () => {

View File

@ -1,8 +1,8 @@
import type { Node } from '@/app/components/workflow/types'
import { createWorkflowStore } from '../workflow'
import { createTestWorkflowStore } from '../../__tests__/workflow-test-env'
function createStore() {
return createWorkflowStore({})
return createTestWorkflowStore()
}
describe('Workflow Draft Slice', () => {
@ -69,13 +69,20 @@ describe('Workflow Draft Slice', () => {
})
describe('debouncedSyncWorkflowDraft', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should be a callable function', () => {
const store = createStore()
expect(typeof store.getState().debouncedSyncWorkflowDraft).toBe('function')
})
it('should debounce the sync call', () => {
vi.useFakeTimers()
const store = createStore()
const syncFn = vi.fn()
@ -84,12 +91,9 @@ describe('Workflow Draft Slice', () => {
vi.advanceTimersByTime(5000)
expect(syncFn).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it('should flush pending sync via flushPendingSync', () => {
vi.useFakeTimers()
const store = createStore()
const syncFn = vi.fn()
@ -98,8 +102,6 @@ describe('Workflow Draft Slice', () => {
store.getState().flushPendingSync()
expect(syncFn).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
})
})

View File

@ -1,18 +1,29 @@
import type { Shape, SliceFromInjection } from '../workflow'
import type { HelpLineHorizontalPosition, HelpLineVerticalPosition } from '@/app/components/workflow/help-line/types'
import type { WorkflowRunningData } from '@/app/components/workflow/types'
import type { FileUploadConfigResponse } from '@/models/common'
import type { VersionHistory } from '@/types/workflow'
import { renderHook } from '@testing-library/react'
import * as React from 'react'
import { BlockEnum } from '@/app/components/workflow/types'
import { WorkflowContext } from '../../context'
import { createTestWorkflowStore, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { createWorkflowStore, useStore, useWorkflowStore } from '../workflow'
function createStore() {
return createWorkflowStore({})
return createTestWorkflowStore()
}
type SetterKey = keyof Shape & `set${string}`
type StateKey = Exclude<keyof Shape, SetterKey>
/**
* Verifies a simple setter → state round-trip:
* calling state[setter](value) should update state[stateKey] to equal value.
*/
function testSetter(setter: SetterKey, stateKey: StateKey, value: Shape[StateKey]) {
const store = createStore()
const setFn = store.getState()[setter] as (v: Shape[StateKey]) => void
setFn(value)
expect(store.getState()[stateKey]).toEqual(value)
}
const emptyIterParallelLogMap = new Map<string, Map<string, never[]>>()
describe('createWorkflowStore', () => {
describe('Initial State', () => {
it('should create a store with all slices merged', () => {
@ -32,60 +43,23 @@ describe('createWorkflowStore', () => {
})
describe('Workflow Slice Setters', () => {
it('should update workflowRunningData', () => {
const store = createStore()
const data: Partial<WorkflowRunningData> = { result: { status: 'running', inputs_truncated: false, process_data_truncated: false, outputs_truncated: false } }
store.getState().setWorkflowRunningData(data as Parameters<Shape['setWorkflowRunningData']>[0])
expect(store.getState().workflowRunningData).toEqual(data)
})
it('should update isListening', () => {
const store = createStore()
store.getState().setIsListening(true)
expect(store.getState().isListening).toBe(true)
})
it('should update listeningTriggerType', () => {
const store = createStore()
store.getState().setListeningTriggerType(BlockEnum.TriggerWebhook)
expect(store.getState().listeningTriggerType).toBe(BlockEnum.TriggerWebhook)
})
it('should update listeningTriggerNodeId', () => {
const store = createStore()
store.getState().setListeningTriggerNodeId('node-abc')
expect(store.getState().listeningTriggerNodeId).toBe('node-abc')
})
it('should update listeningTriggerNodeIds', () => {
const store = createStore()
store.getState().setListeningTriggerNodeIds(['n1', 'n2'])
expect(store.getState().listeningTriggerNodeIds).toEqual(['n1', 'n2'])
})
it('should update listeningTriggerIsAll', () => {
const store = createStore()
store.getState().setListeningTriggerIsAll(true)
expect(store.getState().listeningTriggerIsAll).toBe(true)
})
it('should update clipboardElements', () => {
const store = createStore()
store.getState().setClipboardElements([])
expect(store.getState().clipboardElements).toEqual([])
})
it('should update selection', () => {
const store = createStore()
const sel = { x1: 0, y1: 0, x2: 100, y2: 100 }
store.getState().setSelection(sel)
expect(store.getState().selection).toEqual(sel)
})
it('should update bundleNodeSize', () => {
const store = createStore()
store.getState().setBundleNodeSize({ width: 200, height: 100 })
expect(store.getState().bundleNodeSize).toEqual({ width: 200, height: 100 })
it.each<[StateKey, SetterKey, Shape[StateKey]]>([
['workflowRunningData', 'setWorkflowRunningData', { result: { status: 'running', inputs_truncated: false, process_data_truncated: false, outputs_truncated: false } }],
['isListening', 'setIsListening', true],
['listeningTriggerType', 'setListeningTriggerType', BlockEnum.TriggerWebhook],
['listeningTriggerNodeId', 'setListeningTriggerNodeId', 'node-abc'],
['listeningTriggerNodeIds', 'setListeningTriggerNodeIds', ['n1', 'n2']],
['listeningTriggerIsAll', 'setListeningTriggerIsAll', true],
['clipboardElements', 'setClipboardElements', []],
['selection', 'setSelection', { x1: 0, y1: 0, x2: 100, y2: 100 }],
['bundleNodeSize', 'setBundleNodeSize', { width: 200, height: 100 }],
['mousePosition', 'setMousePosition', { pageX: 10, pageY: 20, elementX: 5, elementY: 15 }],
['showConfirm', 'setShowConfirm', { title: 'Delete?', onConfirm: vi.fn() }],
['controlPromptEditorRerenderKey', 'setControlPromptEditorRerenderKey', 42],
['showImportDSLModal', 'setShowImportDSLModal', true],
['fileUploadConfig', 'setFileUploadConfig', { batch_count_limit: 5, image_file_batch_limit: 10, single_chunk_attachment_limit: 10, attachment_image_file_size_limit: 2, file_size_limit: 15, file_upload_limit: 5 }],
])('should update %s', (stateKey, setter, value) => {
testSetter(setter, stateKey, value)
})
it('should persist controlMode to localStorage', () => {
@ -94,180 +68,48 @@ describe('createWorkflowStore', () => {
expect(store.getState().controlMode).toBe('pointer')
expect(localStorage.setItem).toHaveBeenCalledWith('workflow-operation-mode', 'pointer')
})
it('should update mousePosition', () => {
const store = createStore()
const pos = { pageX: 10, pageY: 20, elementX: 5, elementY: 15 }
store.getState().setMousePosition(pos)
expect(store.getState().mousePosition).toEqual(pos)
})
it('should update showConfirm', () => {
const store = createStore()
const confirm = { title: 'Delete?', onConfirm: vi.fn() }
store.getState().setShowConfirm(confirm)
expect(store.getState().showConfirm).toEqual(confirm)
})
it('should update controlPromptEditorRerenderKey', () => {
const store = createStore()
store.getState().setControlPromptEditorRerenderKey(42)
expect(store.getState().controlPromptEditorRerenderKey).toBe(42)
})
it('should update showImportDSLModal', () => {
const store = createStore()
store.getState().setShowImportDSLModal(true)
expect(store.getState().showImportDSLModal).toBe(true)
})
it('should update fileUploadConfig', () => {
const store = createStore()
const config: FileUploadConfigResponse = {
batch_count_limit: 5,
image_file_batch_limit: 10,
single_chunk_attachment_limit: 10,
attachment_image_file_size_limit: 2,
file_size_limit: 15,
file_upload_limit: 5,
}
store.getState().setFileUploadConfig(config)
expect(store.getState().fileUploadConfig).toEqual(config)
})
})
describe('Node Slice Setters', () => {
it('should update showSingleRunPanel', () => {
const store = createStore()
store.getState().setShowSingleRunPanel(true)
expect(store.getState().showSingleRunPanel).toBe(true)
})
it('should update nodeAnimation', () => {
const store = createStore()
store.getState().setNodeAnimation(true)
expect(store.getState().nodeAnimation).toBe(true)
})
it('should update candidateNode', () => {
const store = createStore()
store.getState().setCandidateNode(undefined)
expect(store.getState().candidateNode).toBeUndefined()
})
it('should update nodeMenu', () => {
const store = createStore()
store.getState().setNodeMenu({ top: 100, left: 200, nodeId: 'n1' })
expect(store.getState().nodeMenu).toEqual({ top: 100, left: 200, nodeId: 'n1' })
})
it('should update showAssignVariablePopup', () => {
const store = createStore()
store.getState().setShowAssignVariablePopup(undefined)
expect(store.getState().showAssignVariablePopup).toBeUndefined()
})
it('should update hoveringAssignVariableGroupId', () => {
const store = createStore()
store.getState().setHoveringAssignVariableGroupId('group-1')
expect(store.getState().hoveringAssignVariableGroupId).toBe('group-1')
})
it('should update connectingNodePayload', () => {
const store = createStore()
const payload = { nodeId: 'n1', nodeType: 'llm', handleType: 'source', handleId: 'h1' }
store.getState().setConnectingNodePayload(payload)
expect(store.getState().connectingNodePayload).toEqual(payload)
})
it('should update enteringNodePayload', () => {
const store = createStore()
store.getState().setEnteringNodePayload(undefined)
expect(store.getState().enteringNodePayload).toBeUndefined()
})
it('should update iterTimes', () => {
const store = createStore()
store.getState().setIterTimes(5)
expect(store.getState().iterTimes).toBe(5)
})
it('should update loopTimes', () => {
const store = createStore()
store.getState().setLoopTimes(10)
expect(store.getState().loopTimes).toBe(10)
})
it('should update iterParallelLogMap', () => {
const store = createStore()
const map = new Map<string, Map<string, never[]>>()
store.getState().setIterParallelLogMap(map)
expect(store.getState().iterParallelLogMap).toBe(map)
})
it('should update pendingSingleRun', () => {
const store = createStore()
store.getState().setPendingSingleRun({ nodeId: 'n1', action: 'run' })
expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'n1', action: 'run' })
it.each<[StateKey, SetterKey, Shape[StateKey]]>([
['showSingleRunPanel', 'setShowSingleRunPanel', true],
['nodeAnimation', 'setNodeAnimation', true],
['candidateNode', 'setCandidateNode', undefined],
['nodeMenu', 'setNodeMenu', { top: 100, left: 200, nodeId: 'n1' }],
['showAssignVariablePopup', 'setShowAssignVariablePopup', undefined],
['hoveringAssignVariableGroupId', 'setHoveringAssignVariableGroupId', 'group-1'],
['connectingNodePayload', 'setConnectingNodePayload', { nodeId: 'n1', nodeType: 'llm', handleType: 'source', handleId: 'h1' }],
['enteringNodePayload', 'setEnteringNodePayload', undefined],
['iterTimes', 'setIterTimes', 5],
['loopTimes', 'setLoopTimes', 10],
['iterParallelLogMap', 'setIterParallelLogMap', emptyIterParallelLogMap],
['pendingSingleRun', 'setPendingSingleRun', { nodeId: 'n1', action: 'run' }],
])('should update %s', (stateKey, setter, value) => {
testSetter(setter, stateKey, value)
})
})
describe('Panel Slice Setters', () => {
it('should update showFeaturesPanel', () => {
const store = createStore()
store.getState().setShowFeaturesPanel(true)
expect(store.getState().showFeaturesPanel).toBe(true)
})
it('should update showWorkflowVersionHistoryPanel', () => {
const store = createStore()
store.getState().setShowWorkflowVersionHistoryPanel(true)
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(true)
})
it('should update showInputsPanel', () => {
const store = createStore()
store.getState().setShowInputsPanel(true)
expect(store.getState().showInputsPanel).toBe(true)
})
it('should update showDebugAndPreviewPanel', () => {
const store = createStore()
store.getState().setShowDebugAndPreviewPanel(true)
expect(store.getState().showDebugAndPreviewPanel).toBe(true)
})
it('should update panelMenu', () => {
const store = createStore()
store.getState().setPanelMenu({ top: 10, left: 20 })
expect(store.getState().panelMenu).toEqual({ top: 10, left: 20 })
})
it('should update selectionMenu', () => {
const store = createStore()
store.getState().setSelectionMenu({ top: 50, left: 60 })
expect(store.getState().selectionMenu).toEqual({ top: 50, left: 60 })
})
it('should update showVariableInspectPanel', () => {
const store = createStore()
store.getState().setShowVariableInspectPanel(true)
expect(store.getState().showVariableInspectPanel).toBe(true)
})
it('should update initShowLastRunTab', () => {
const store = createStore()
store.getState().setInitShowLastRunTab(true)
expect(store.getState().initShowLastRunTab).toBe(true)
it.each<[StateKey, SetterKey, Shape[StateKey]]>([
['showFeaturesPanel', 'setShowFeaturesPanel', true],
['showWorkflowVersionHistoryPanel', 'setShowWorkflowVersionHistoryPanel', true],
['showInputsPanel', 'setShowInputsPanel', true],
['showDebugAndPreviewPanel', 'setShowDebugAndPreviewPanel', true],
['panelMenu', 'setPanelMenu', { top: 10, left: 20 }],
['selectionMenu', 'setSelectionMenu', { top: 50, left: 60 }],
['showVariableInspectPanel', 'setShowVariableInspectPanel', true],
['initShowLastRunTab', 'setInitShowLastRunTab', true],
])('should update %s', (stateKey, setter, value) => {
testSetter(setter, stateKey, value)
})
})
describe('Help Line Slice Setters', () => {
it('should update helpLineHorizontal', () => {
const store = createStore()
const pos: HelpLineHorizontalPosition = { top: 100, left: 0, width: 500 }
store.getState().setHelpLineHorizontal(pos)
expect(store.getState().helpLineHorizontal).toEqual(pos)
it.each<[StateKey, SetterKey, Shape[StateKey]]>([
['helpLineHorizontal', 'setHelpLineHorizontal', { top: 100, left: 0, width: 500 }],
['helpLineVertical', 'setHelpLineVertical', { top: 0, left: 200, height: 300 }],
])('should update %s', (stateKey, setter, value) => {
testSetter(setter, stateKey, value)
})
it('should clear helpLineHorizontal', () => {
@ -276,123 +118,50 @@ describe('createWorkflowStore', () => {
store.getState().setHelpLineHorizontal(undefined)
expect(store.getState().helpLineHorizontal).toBeUndefined()
})
it('should update helpLineVertical', () => {
const store = createStore()
const pos: HelpLineVerticalPosition = { top: 0, left: 200, height: 300 }
store.getState().setHelpLineVertical(pos)
expect(store.getState().helpLineVertical).toEqual(pos)
})
})
describe('History Slice Setters', () => {
it('should update historyWorkflowData', () => {
const store = createStore()
store.getState().setHistoryWorkflowData({ id: 'run-1', status: 'succeeded' })
expect(store.getState().historyWorkflowData).toEqual({ id: 'run-1', status: 'succeeded' })
})
it('should update showRunHistory', () => {
const store = createStore()
store.getState().setShowRunHistory(true)
expect(store.getState().showRunHistory).toBe(true)
})
it('should update versionHistory', () => {
const store = createStore()
const history: VersionHistory[] = []
store.getState().setVersionHistory(history)
expect(store.getState().versionHistory).toEqual(history)
it.each<[StateKey, SetterKey, Shape[StateKey]]>([
['historyWorkflowData', 'setHistoryWorkflowData', { id: 'run-1', status: 'succeeded' }],
['showRunHistory', 'setShowRunHistory', true],
['versionHistory', 'setVersionHistory', []],
])('should update %s', (stateKey, setter, value) => {
testSetter(setter, stateKey, value)
})
})
describe('Form Slice Setters', () => {
it('should update inputs', () => {
const store = createStore()
store.getState().setInputs({ name: 'test', count: 42 })
expect(store.getState().inputs).toEqual({ name: 'test', count: 42 })
})
it('should update files', () => {
const store = createStore()
store.getState().setFiles([])
expect(store.getState().files).toEqual([])
it.each<[StateKey, SetterKey, Shape[StateKey]]>([
['inputs', 'setInputs', { name: 'test', count: 42 }],
['files', 'setFiles', []],
])('should update %s', (stateKey, setter, value) => {
testSetter(setter, stateKey, value)
})
})
describe('Tool Slice Setters', () => {
it('should update toolPublished', () => {
const store = createStore()
store.getState().setToolPublished(true)
expect(store.getState().toolPublished).toBe(true)
})
it('should update lastPublishedHasUserInput', () => {
const store = createStore()
store.getState().setLastPublishedHasUserInput(true)
expect(store.getState().lastPublishedHasUserInput).toBe(true)
it.each<[StateKey, SetterKey, Shape[StateKey]]>([
['toolPublished', 'setToolPublished', true],
['lastPublishedHasUserInput', 'setLastPublishedHasUserInput', true],
])('should update %s', (stateKey, setter, value) => {
testSetter(setter, stateKey, value)
})
})
describe('Layout Slice Setters', () => {
it('should update workflowCanvasWidth', () => {
const store = createStore()
store.getState().setWorkflowCanvasWidth(1200)
expect(store.getState().workflowCanvasWidth).toBe(1200)
})
it('should update workflowCanvasHeight', () => {
const store = createStore()
store.getState().setWorkflowCanvasHeight(800)
expect(store.getState().workflowCanvasHeight).toBe(800)
})
it('should update rightPanelWidth', () => {
const store = createStore()
store.getState().setRightPanelWidth(500)
expect(store.getState().rightPanelWidth).toBe(500)
})
it('should update nodePanelWidth', () => {
const store = createStore()
store.getState().setNodePanelWidth(350)
expect(store.getState().nodePanelWidth).toBe(350)
})
it('should update previewPanelWidth', () => {
const store = createStore()
store.getState().setPreviewPanelWidth(450)
expect(store.getState().previewPanelWidth).toBe(450)
})
it('should update otherPanelWidth', () => {
const store = createStore()
store.getState().setOtherPanelWidth(380)
expect(store.getState().otherPanelWidth).toBe(380)
})
it('should update bottomPanelWidth', () => {
const store = createStore()
store.getState().setBottomPanelWidth(600)
expect(store.getState().bottomPanelWidth).toBe(600)
})
it('should update bottomPanelHeight', () => {
const store = createStore()
store.getState().setBottomPanelHeight(500)
expect(store.getState().bottomPanelHeight).toBe(500)
})
it('should update variableInspectPanelHeight', () => {
const store = createStore()
store.getState().setVariableInspectPanelHeight(250)
expect(store.getState().variableInspectPanelHeight).toBe(250)
})
it('should update maximizeCanvas', () => {
const store = createStore()
store.getState().setMaximizeCanvas(true)
expect(store.getState().maximizeCanvas).toBe(true)
it.each<[StateKey, SetterKey, Shape[StateKey]]>([
['workflowCanvasWidth', 'setWorkflowCanvasWidth', 1200],
['workflowCanvasHeight', 'setWorkflowCanvasHeight', 800],
['rightPanelWidth', 'setRightPanelWidth', 500],
['nodePanelWidth', 'setNodePanelWidth', 350],
['previewPanelWidth', 'setPreviewPanelWidth', 450],
['otherPanelWidth', 'setOtherPanelWidth', 380],
['bottomPanelWidth', 'setBottomPanelWidth', 600],
['bottomPanelHeight', 'setBottomPanelHeight', 500],
['variableInspectPanelHeight', 'setVariableInspectPanelHeight', 250],
['maximizeCanvas', 'setMaximizeCanvas', true],
])('should update %s', (stateKey, setter, value) => {
testSetter(setter, stateKey, value)
})
})
@ -446,13 +215,10 @@ describe('createWorkflowStore', () => {
describe('useStore hook', () => {
it('should read state via selector when wrapped in WorkflowContext', () => {
const store = createStore()
store.getState().setShowSingleRunPanel(true)
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(WorkflowContext.Provider, { value: store }, children)
const { result } = renderHook(() => useStore(s => s.showSingleRunPanel), { wrapper })
const { result } = renderWorkflowHook(
() => useStore(s => s.showSingleRunPanel),
{ initialStoreState: { showSingleRunPanel: true } },
)
expect(result.current).toBe(true)
})
@ -465,11 +231,7 @@ describe('createWorkflowStore', () => {
describe('useWorkflowStore hook', () => {
it('should return the store instance when wrapped in WorkflowContext', () => {
const store = createStore()
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(WorkflowContext.Provider, { value: store }, children)
const { result } = renderHook(() => useWorkflowStore(), { wrapper })
const { result, store } = renderWorkflowHook(() => useWorkflowStore())
expect(result.current).toBe(store)
})
})

View File

@ -13,7 +13,6 @@ import { TooltipProvider } from './components/base/ui/tooltip'
import BrowserInitializer from './components/browser-initializer'
import { ReactScanLoader } from './components/devtools/react-scan/loader'
import { I18nServerProvider } from './components/provider/i18n-server'
import { PWAProvider } from './components/provider/serwist'
import SentryInitializer from './components/sentry-initializer'
import RoutePrefixHandle from './routePrefixHandle'
import './styles/globals.css'
@ -64,36 +63,34 @@ const LocaleLayout = async ({
{...datasetMap}
>
<div className="isolate h-full">
<PWAProvider>
<JotaiProvider>
<ThemeProvider
attribute="data-theme"
defaultTheme="system"
enableSystem
disableTransitionOnChange
enableColorScheme={false}
>
<NuqsAdapter>
<BrowserInitializer>
<SentryInitializer>
<TanstackQueryInitializer>
<I18nServerProvider>
<ToastProvider>
<GlobalPublicStoreProvider>
<TooltipProvider delay={300} closeDelay={200}>
{children}
</TooltipProvider>
</GlobalPublicStoreProvider>
</ToastProvider>
</I18nServerProvider>
</TanstackQueryInitializer>
</SentryInitializer>
</BrowserInitializer>
</NuqsAdapter>
</ThemeProvider>
</JotaiProvider>
<RoutePrefixHandle />
</PWAProvider>
<JotaiProvider>
<ThemeProvider
attribute="data-theme"
defaultTheme="system"
enableSystem
disableTransitionOnChange
enableColorScheme={false}
>
<NuqsAdapter>
<BrowserInitializer>
<SentryInitializer>
<TanstackQueryInitializer>
<I18nServerProvider>
<ToastProvider>
<GlobalPublicStoreProvider>
<TooltipProvider delay={300} closeDelay={200}>
{children}
</TooltipProvider>
</GlobalPublicStoreProvider>
</ToastProvider>
</I18nServerProvider>
</TanstackQueryInitializer>
</SentryInitializer>
</BrowserInitializer>
</NuqsAdapter>
</ThemeProvider>
</JotaiProvider>
<RoutePrefixHandle />
</div>
</body>
</html>

View File

@ -1,12 +0,0 @@
import { createSerwistRoute } from '@serwist/turbopack'
import { env } from '@/env'
const basePath = env.NEXT_PUBLIC_BASE_PATH
export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } = createSerwistRoute({
swSrc: 'app/sw.ts',
nextConfig: {
basePath,
},
useNativeEsbuild: true,
})

View File

@ -1,58 +0,0 @@
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'
import { defaultCache } from '@serwist/turbopack/worker'
import { Serwist } from 'serwist'
import { withLeadingSlash } from 'ufo'
declare global {
// eslint-disable-next-line ts/consistent-type-definitions
interface WorkerGlobalScope extends SerwistGlobalConfig {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined
}
}
declare const self: ServiceWorkerGlobalScope
const scopePathname = new URL(self.registration.scope).pathname
const basePath = scopePathname.replace(/\/serwist\/$/, '').replace(/\/$/, '')
const offlineUrl = `${basePath}/_offline.html`
const normalizeManifestUrl = (url: string): string => {
if (url.startsWith('/serwist/'))
return url.replace(/^\/serwist\//, '/')
return withLeadingSlash(url)
}
const manifest = self.__SW_MANIFEST?.map((entry) => {
if (typeof entry === 'string')
return normalizeManifestUrl(entry)
return {
...entry,
url: normalizeManifestUrl(entry.url),
}
})
const serwist = new Serwist({
precacheEntries: manifest,
skipWaiting: true,
disableDevLogs: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: defaultCache,
fallbacks: {
entries: [
{
url: offlineUrl,
matcher({ request }) {
return request.destination === 'document'
},
},
],
},
})
serwist.addEventListeners()

View File

@ -0,0 +1,17 @@
import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
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[]
}>())

View File

@ -12,6 +12,7 @@ import {
exploreInstalledAppsContract,
exploreInstalledAppUninstallContract,
} from './console/explore'
import { modelProvidersModelsContract } from './console/model-providers'
import { systemFeaturesContract } from './console/system'
import {
triggerOAuthConfigContract,
@ -63,6 +64,9 @@ export const consoleRouterContract = {
parameters: trialAppParametersContract,
workflows: trialAppWorkflowsContract,
},
modelProviders: {
models: modelProvidersModelsContract,
},
billing: {
invoices: invoicesContract,
bindPartnerStack: bindPartnerStackContract,

View File

@ -2964,16 +2964,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
@ -4733,11 +4723,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
@ -4864,11 +4849,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
@ -4878,14 +4858,6 @@
}
},
"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
}
@ -4930,24 +4902,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
@ -5345,11 +5304,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
@ -5439,14 +5393,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
@ -5783,27 +5729,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
@ -9709,17 +9634,6 @@
"count": 1
}
},
"lib/utils.ts": {
"import/consistent-type-specifier-style": {
"count": 1
},
"perfectionist/sort-named-imports": {
"count": 1
},
"style/quotes": {
"count": 2
}
},
"models/common.ts": {
"ts/no-explicit-any": {
"count": 3

View File

@ -340,18 +340,19 @@
"modelProvider.auth.unAuthorized": "Unauthorized",
"modelProvider.buyQuota": "Buy Quota",
"modelProvider.callTimes": "Call times",
"modelProvider.card.aiCreditsInUse": "AI credits in use",
"modelProvider.card.buyQuota": "Buy Quota",
"modelProvider.card.callTimes": "Call times",
"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.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.collapse": "Collapse",
"modelProvider.config": "Config",
@ -397,7 +398,7 @@
"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",
@ -431,7 +432,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",

View File

@ -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",

View File

@ -340,18 +340,19 @@
"modelProvider.auth.unAuthorized": "未授权",
"modelProvider.buyQuota": "购买额度",
"modelProvider.callTimes": "调用次数",
"modelProvider.card.aiCreditsInUse": "AI 额度使用中",
"modelProvider.card.buyQuota": "购买额度",
"modelProvider.card.callTimes": "调用次数",
"modelProvider.card.modelAPI": "{{modelName}} 模型正在使用 API Key。",
"modelProvider.card.modelNotSupported": "{{modelName}} 模型未安装",
"modelProvider.card.modelNotSupported": "{{modelName}} 未安装",
"modelProvider.card.modelSupported": "{{modelName}} 模型正在使用此额度。",
"modelProvider.card.onTrial": "试用中",
"modelProvider.card.paid": "已购买",
"modelProvider.card.priorityUse": "优先使用",
"modelProvider.card.quota": "额度",
"modelProvider.card.quotaExhausted": "额已用",
"modelProvider.card.quotaExhausted": "额已用",
"modelProvider.card.removeKey": "删除 API 密钥",
"modelProvider.card.tip": "消息额度支持使用 {{modelNames}} 的模型;免费额度会在付费额度用尽后才会消耗。",
"modelProvider.card.tip": "AI Credits 支持使用 {{modelNames}} 的模型;试用额度会在付费额度用尽后才会消耗。",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "收起",
"modelProvider.config": "配置",
@ -397,7 +398,7 @@
"modelProvider.priorityUsing": "优先使用",
"modelProvider.providerManaged": "由模型供应商管理",
"modelProvider.providerManagedDescription": "使用模型供应商提供的单组凭据",
"modelProvider.quota": "额度",
"modelProvider.quota": "AI Credits",
"modelProvider.quotaTip": "剩余免费额度",
"modelProvider.rerankModel.key": "Rerank 模型",
"modelProvider.rerankModel.tip": "重排序模型将根据候选文档列表与用户问题语义匹配度进行重新排序,从而改进语义排序的结果",

View File

@ -3,6 +3,7 @@
"action.delete": "移除插件",
"action.deleteContentLeft": "是否要移除 ",
"action.deleteContentRight": " 插件?",
"action.deleteSuccess": "插件移除成功",
"action.pluginInfo": "插件信息",
"action.usedInApps": "此插件正在 {{num}} 个应用中使用。",
"allCategories": "所有类别",
@ -114,7 +115,7 @@
"detailPanel.operation.install": "安装",
"detailPanel.operation.remove": "移除",
"detailPanel.operation.update": "更新",
"detailPanel.operation.viewDetail": "查看详情",
"detailPanel.operation.viewDetail": "在 Marketplace 查看",
"detailPanel.serviceOk": "服务正常",
"detailPanel.strategyNum": "包含 {{num}} 个 {{strategy}}",
"detailPanel.switchVersion": "切换版本",

View File

@ -25,7 +25,6 @@ const remoteImageURLs = ([hasSetWebPrefix ? new URL(`${env.NEXT_PUBLIC_WEB_PREFI
const nextConfig: NextConfig = {
basePath: env.NEXT_PUBLIC_BASE_PATH,
serverExternalPackages: ['esbuild'],
transpilePackages: ['@t3-oss/env-core', '@t3-oss/env-nextjs', 'echarts', 'zrender'],
turbopack: {
rules: codeInspectorPlugin({

View File

@ -161,7 +161,6 @@
"string-ts": "2.3.1",
"tailwind-merge": "2.6.1",
"tldts": "7.0.17",
"ufo": "1.6.3",
"use-context-selector": "2.0.0",
"uuid": "10.0.0",
"zod": "4.3.6",
@ -181,7 +180,6 @@
"@next/eslint-plugin-next": "16.1.6",
"@next/mdx": "16.1.5",
"@rgrove/parse-xml": "4.2.0",
"@serwist/turbopack": "9.5.4",
"@storybook/addon-docs": "10.2.13",
"@storybook/addon-links": "10.2.13",
"@storybook/addon-onboarding": "10.2.13",
@ -221,7 +219,6 @@
"autoprefixer": "10.4.21",
"code-inspector-plugin": "1.3.6",
"cross-env": "10.1.0",
"esbuild": "0.27.2",
"eslint": "10.0.2",
"eslint-plugin-better-tailwindcss": "https://pkg.pr.new/hyoban/eslint-plugin-better-tailwindcss@a520d15",
"eslint-plugin-hyoban": "0.11.2",
@ -241,7 +238,6 @@
"react-scan": "0.5.3",
"react-server-dom-webpack": "19.2.4",
"sass": "1.93.2",
"serwist": "9.5.4",
"storybook": "10.2.13",
"tailwindcss": "3.4.19",
"tsx": "4.21.0",

412
web/pnpm-lock.yaml generated
View File

@ -5,11 +5,6 @@ settings:
excludeLinksFromLockfile: false
overrides:
brace-expansion: ~2.0
canvas: ^3.2.0
pbkdf2: ~3.1.3
prismjs: ~1.30
string-width: ~4.2.3
'@monaco-editor/loader': 1.5.0
'@nolyfill/safe-buffer': npm:safe-buffer@^5.2.1
'@stylistic/eslint-plugin': https://pkg.pr.new/@stylistic/eslint-plugin@258f9d8
@ -20,7 +15,9 @@ overrides:
array.prototype.flatmap: npm:@nolyfill/array.prototype.flatmap@^1
array.prototype.tosorted: npm:@nolyfill/array.prototype.tosorted@^1
assert: npm:@nolyfill/assert@^1
brace-expansion: ~2.0
brace-expansion@<2.0.2: 2.0.2
canvas: ^3.2.0
devalue@<5.3.2: 5.3.2
es-iterator-helpers: npm:@nolyfill/es-iterator-helpers@^1
esbuild@<0.27.2: 0.27.2
@ -36,13 +33,16 @@ overrides:
object.fromentries: npm:@nolyfill/object.fromentries@^1
object.groupby: npm:@nolyfill/object.groupby@^1
object.values: npm:@nolyfill/object.values@^1
pbkdf2: ~3.1.3
pbkdf2@<3.1.3: 3.1.3
prismjs: ~1.30
prismjs@<1.30.0: 1.30.0
safe-buffer: ^5.2.1
safe-regex-test: npm:@nolyfill/safe-regex-test@^1
safer-buffer: npm:@nolyfill/safer-buffer@^1
side-channel: npm:@nolyfill/side-channel@^1
solid-js: 1.9.11
string-width: ~4.2.3
string.prototype.includes: npm:@nolyfill/string.prototype.includes@^1
string.prototype.matchall: npm:@nolyfill/string.prototype.matchall@^1
string.prototype.repeat: npm:@nolyfill/string.prototype.repeat@^1
@ -354,9 +354,6 @@ importers:
tldts:
specifier: 7.0.17
version: 7.0.17
ufo:
specifier: 1.6.3
version: 1.6.3
use-context-selector:
specifier: 2.0.0
version: 2.0.0(react@19.2.4)(scheduler@0.27.0)
@ -409,9 +406,6 @@ importers:
'@rgrove/parse-xml':
specifier: 4.2.0
version: 4.2.0
'@serwist/turbopack':
specifier: 9.5.4
version: 9.5.4(@swc/helpers@0.5.18)(esbuild-wasm@0.27.2)(esbuild@0.27.2)(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react@19.2.4)(typescript@5.9.3)
'@storybook/addon-docs':
specifier: 10.2.13
version: 10.2.13(@types/react@19.2.9)(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
@ -529,9 +523,6 @@ importers:
cross-env:
specifier: 10.1.0
version: 10.1.0
esbuild:
specifier: 0.27.2
version: 0.27.2
eslint:
specifier: 10.0.2
version: 10.0.2(jiti@1.21.7)
@ -589,9 +580,6 @@ importers:
sass:
specifier: 1.93.2
version: 1.93.2
serwist:
specifier: 9.5.4
version: 9.5.4(browserslist@4.28.1)(typescript@5.9.3)
storybook:
specifier: 10.2.13
version: 10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -1659,10 +1647,6 @@ packages:
resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
engines: {node: 20 || >=22}
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
@ -2191,6 +2175,11 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@pivanov/utils@0.0.2':
resolution: {integrity: sha512-q9CN0bFWxWgMY5hVVYyBgez1jGiLBa6I+LkG37ycylPhFvEGOOeaADGtUSu46CaZasPnlY8fCdVJZmrgKb1EPA==}
peerDependencies:
react: '>=18'
react-dom: '>=18'
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@ -2745,48 +2734,6 @@ packages:
peerDependencies:
react: ^16.14.0 || 17.x || 18.x || 19.x
'@serwist/build@9.5.4':
resolution: {integrity: sha512-FTiNsNb3luKsLIxjKCvkPiqFZSbx7yVNOFGSUhp4lyfzgnelT1M3/lMC88kLiak90emkuFjSkQgwa6OnyhMZlQ==}
engines: {node: '>=18.0.0'}
peerDependencies:
typescript: '>=5.0.0'
peerDependenciesMeta:
typescript:
optional: true
'@serwist/turbopack@9.5.4':
resolution: {integrity: sha512-HerOIc2z3LWbFVq/gXK44I99KdF+x0uBI7cPHb+Q3q0WpF50d/i5fV5pZZXCf3LCqtc9oH0VlY6FWDcjWjHI8g==}
engines: {node: '>=18.0.0'}
peerDependencies:
esbuild: 0.27.2
esbuild-wasm: '>=0.25.0 <1.0.0'
next: '>=14.0.0'
react: '>=18.0.0'
typescript: '>=5.0.0'
peerDependenciesMeta:
esbuild:
optional: true
esbuild-wasm:
optional: true
typescript:
optional: true
'@serwist/utils@9.5.4':
resolution: {integrity: sha512-uyriGQF1qjNEHXXfsd8XJ5kfK3/MezEaUw//XdHjZeJ0LvLamrgnLJGQQoyJqUfEPCiJ4jJwc4uYMB9LjLiHxA==}
peerDependencies:
browserslist: '>=4'
peerDependenciesMeta:
browserslist:
optional: true
'@serwist/window@9.5.4':
resolution: {integrity: sha512-52t2G+TgiWDdRwGG0ArU28uy6/oQYICQfNLHs4ywybyS6mHy3BxHFl+JjB5vhg8znIG1LMpGvOmS5b7AuPVYDw==}
peerDependencies:
typescript: '>=5.0.0'
peerDependenciesMeta:
typescript:
optional: true
'@shuding/opentype.js@1.4.0-beta.0':
resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
engines: {node: '>= 8.0.0'}
@ -2938,87 +2885,12 @@ packages:
'@svgdotjs/svg.js@3.2.5':
resolution: {integrity: sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==}
'@swc/core-darwin-arm64@1.15.11':
resolution: {integrity: sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==}
engines: {node: '>=10'}
cpu: [arm64]
os: [darwin]
'@swc/core-darwin-x64@1.15.11':
resolution: {integrity: sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA==}
engines: {node: '>=10'}
cpu: [x64]
os: [darwin]
'@swc/core-linux-arm-gnueabihf@1.15.11':
resolution: {integrity: sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux]
'@swc/core-linux-arm64-gnu@1.15.11':
resolution: {integrity: sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
'@swc/core-linux-arm64-musl@1.15.11':
resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
'@swc/core-linux-x64-gnu@1.15.11':
resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
'@swc/core-linux-x64-musl@1.15.11':
resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
'@swc/core-win32-arm64-msvc@1.15.11':
resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@swc/core-win32-ia32-msvc@1.15.11':
resolution: {integrity: sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw==}
engines: {node: '>=10'}
cpu: [ia32]
os: [win32]
'@swc/core-win32-x64-msvc@1.15.11':
resolution: {integrity: sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@swc/core@1.15.11':
resolution: {integrity: sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==}
engines: {node: '>=10'}
peerDependencies:
'@swc/helpers': '>=0.5.17'
peerDependenciesMeta:
'@swc/helpers':
optional: true
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@swc/helpers@0.5.18':
resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==}
'@swc/types@0.1.25':
resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
'@t3-oss/env-core@0.13.10':
resolution: {integrity: sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g==}
peerDependencies:
@ -4261,10 +4133,6 @@ packages:
resolution: {integrity: sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==}
engines: {node: '>= 12.0.0'}
common-tags@1.8.2:
resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==}
engines: {node: '>=4.0.0'}
compare-versions@6.1.1:
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
@ -4726,11 +4594,6 @@ packages:
esast-util-from-js@2.0.1:
resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==}
esbuild-wasm@0.27.2:
resolution: {integrity: sha512-eUTnl8eh+v8UZIZh4MrMOKDAc8Lm7+NqP3pyuTORGFY1s/o9WoiJgKnwXy+te2J3hX7iRbFSHEyig7GsPeeJyw==}
engines: {node: '>=18'}
hasBin: true
esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
@ -5205,10 +5068,6 @@ packages:
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
format@0.2.2:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
@ -5283,11 +5142,6 @@ packages:
glob-to-regexp@0.4.1:
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
@ -5631,9 +5485,6 @@ packages:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jest-worker@27.5.1:
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
engines: {node: '>= 10.13.0'}
@ -5917,9 +5768,6 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.sortby@4.7.0:
resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
@ -5940,9 +5788,6 @@ packages:
lowlight@1.20.0:
resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.5:
resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==}
engines: {node: 20 || >=22}
@ -6237,10 +6082,6 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
@ -6425,9 +6266,6 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
@ -6493,10 +6331,6 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
path-scurry@1.11.1:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-scurry@2.0.2:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
@ -6650,10 +6484,6 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
pretty-bytes@6.1.1:
resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==}
engines: {node: ^14.13.1 || >=16.0.0}
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@ -7119,14 +6949,6 @@ packages:
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
serwist@9.5.4:
resolution: {integrity: sha512-uTHBzpIeA6rE3oyRt392MbtNQDs2JVZelKD1KkT18UkhX6HRwCeassoI1Nd1h52DqYqa7ZfBeldJ4awy+PYrnQ==}
peerDependencies:
typescript: '>=5.0.0'
peerDependenciesMeta:
typescript:
optional: true
sharp@0.33.5:
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@ -7198,11 +7020,6 @@ packages:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
source-map@0.8.0-beta.0:
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
engines: {node: '>= 8'}
deprecated: The work that was done in this beta branch won't be included in future versions
space-separated-tokens@1.1.5:
resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==}
@ -7475,9 +7292,6 @@ packages:
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
engines: {node: '>=16'}
tr46@1.0.1:
resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
@ -7944,9 +7758,6 @@ packages:
web-vitals@5.1.0:
resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==}
webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
@ -7985,9 +7796,6 @@ packages:
resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==}
engines: {node: '>=20'}
whatwg-url@7.1.0:
resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@ -8002,14 +7810,6 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@8.1.0:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
wrap-ansi@9.0.2:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
@ -9280,15 +9080,6 @@ snapshots:
dependencies:
'@isaacs/balanced-match': 4.0.1
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 4.2.3
string-width-cjs: string-width@4.2.3
strip-ansi: 7.2.0
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.3
@ -9915,6 +9706,10 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.6
optional: true
'@pivanov/utils@0.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@pkgjs/parseargs@0.11.0':
optional: true
@ -10393,52 +10188,6 @@ snapshots:
hoist-non-react-statics: 3.3.2
react: 19.2.4
'@serwist/build@9.5.4(browserslist@4.28.1)(typescript@5.9.3)':
dependencies:
'@serwist/utils': 9.5.4(browserslist@4.28.1)
common-tags: 1.8.2
glob: 10.5.0
pretty-bytes: 6.1.1
source-map: 0.8.0-beta.0
zod: 4.3.6
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- browserslist
'@serwist/turbopack@9.5.4(@swc/helpers@0.5.18)(esbuild-wasm@0.27.2)(esbuild@0.27.2)(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react@19.2.4)(typescript@5.9.3)':
dependencies:
'@serwist/build': 9.5.4(browserslist@4.28.1)(typescript@5.9.3)
'@serwist/utils': 9.5.4(browserslist@4.28.1)
'@serwist/window': 9.5.4(browserslist@4.28.1)(typescript@5.9.3)
'@swc/core': 1.15.11(@swc/helpers@0.5.18)
browserslist: 4.28.1
kolorist: 1.8.0
next: 16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2)
react: 19.2.4
semver: 7.7.3
serwist: 9.5.4(browserslist@4.28.1)(typescript@5.9.3)
zod: 4.3.6
optionalDependencies:
esbuild: 0.27.2
esbuild-wasm: 0.27.2
typescript: 5.9.3
transitivePeerDependencies:
- '@swc/helpers'
'@serwist/utils@9.5.4(browserslist@4.28.1)':
optionalDependencies:
browserslist: 4.28.1
'@serwist/window@9.5.4(browserslist@4.28.1)(typescript@5.9.3)':
dependencies:
'@types/trusted-types': 2.0.7
serwist: 9.5.4(browserslist@4.28.1)(typescript@5.9.3)
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- browserslist
'@shuding/opentype.js@1.4.0-beta.0':
dependencies:
fflate: 0.7.4
@ -10620,55 +10369,6 @@ snapshots:
'@svgdotjs/svg.js@3.2.5': {}
'@swc/core-darwin-arm64@1.15.11':
optional: true
'@swc/core-darwin-x64@1.15.11':
optional: true
'@swc/core-linux-arm-gnueabihf@1.15.11':
optional: true
'@swc/core-linux-arm64-gnu@1.15.11':
optional: true
'@swc/core-linux-arm64-musl@1.15.11':
optional: true
'@swc/core-linux-x64-gnu@1.15.11':
optional: true
'@swc/core-linux-x64-musl@1.15.11':
optional: true
'@swc/core-win32-arm64-msvc@1.15.11':
optional: true
'@swc/core-win32-ia32-msvc@1.15.11':
optional: true
'@swc/core-win32-x64-msvc@1.15.11':
optional: true
'@swc/core@1.15.11(@swc/helpers@0.5.18)':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.25
optionalDependencies:
'@swc/core-darwin-arm64': 1.15.11
'@swc/core-darwin-x64': 1.15.11
'@swc/core-linux-arm-gnueabihf': 1.15.11
'@swc/core-linux-arm64-gnu': 1.15.11
'@swc/core-linux-arm64-musl': 1.15.11
'@swc/core-linux-x64-gnu': 1.15.11
'@swc/core-linux-x64-musl': 1.15.11
'@swc/core-win32-arm64-msvc': 1.15.11
'@swc/core-win32-ia32-msvc': 1.15.11
'@swc/core-win32-x64-msvc': 1.15.11
'@swc/helpers': 0.5.18
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@ -10677,10 +10377,6 @@ snapshots:
dependencies:
tslib: 2.8.1
'@swc/types@0.1.25':
dependencies:
'@swc/counter': 0.1.3
'@t3-oss/env-core@0.13.10(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(zod@4.3.6)':
optionalDependencies:
typescript: 5.9.3
@ -11186,7 +10882,8 @@ snapshots:
'@types/sortablejs@1.15.8': {}
'@types/trusted-types@2.0.7': {}
'@types/trusted-types@2.0.7':
optional: true
'@types/unist@2.0.11': {}
@ -12090,8 +11787,6 @@ snapshots:
comment-parser@1.4.5: {}
common-tags@1.8.2: {}
compare-versions@6.1.1: {}
confbox@0.1.8: {}
@ -12560,9 +12255,6 @@ snapshots:
esast-util-from-estree: 2.0.0
vfile-message: 4.0.3
esbuild-wasm@0.27.2:
optional: true
esbuild@0.27.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2
@ -13259,11 +12951,6 @@ snapshots:
flatted@3.3.3: {}
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
format@0.2.2: {}
formatly@0.3.0:
@ -13319,15 +13006,6 @@ snapshots:
glob-to-regexp@0.4.1: {}
glob@10.5.0:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 9.0.5
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
glob@13.0.6:
dependencies:
minimatch: 10.2.4
@ -13713,12 +13391,6 @@ snapshots:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
jest-worker@27.5.1:
dependencies:
'@types/node': 24.10.12
@ -13987,8 +13659,6 @@ snapshots:
lodash.merge@4.6.2: {}
lodash.sortby@4.7.0: {}
lodash@4.17.23: {}
log-update@6.1.0:
@ -14012,8 +13682,6 @@ snapshots:
fault: 1.0.4
highlight.js: 10.7.3
lru-cache@10.4.3: {}
lru-cache@11.2.5: {}
lru-cache@11.2.6: {}
@ -14601,8 +14269,6 @@ snapshots:
minimist@1.2.8: {}
minipass@7.1.2: {}
minipass@7.1.3: {}
minizlib@3.1.0:
@ -14791,8 +14457,6 @@ snapshots:
dependencies:
p-limit: 3.1.0
package-json-from-dist@1.0.1: {}
package-manager-detector@1.6.0: {}
pako@0.2.9: {}
@ -14864,11 +14528,6 @@ snapshots:
path-parse@1.0.7: {}
path-scurry@1.11.1:
dependencies:
lru-cache: 10.4.3
minipass: 7.1.2
path-scurry@2.0.2:
dependencies:
lru-cache: 11.2.6
@ -15019,8 +14678,6 @@ snapshots:
prelude-ls@1.2.1: {}
pretty-bytes@6.1.1: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
@ -15640,15 +15297,6 @@ snapshots:
server-only@0.0.1: {}
serwist@9.5.4(browserslist@4.28.1)(typescript@5.9.3):
dependencies:
'@serwist/utils': 9.5.4(browserslist@4.28.1)
idb: 8.0.3
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- browserslist
sharp@0.33.5:
dependencies:
color: 4.2.3
@ -15766,10 +15414,6 @@ snapshots:
source-map@0.7.6: {}
source-map@0.8.0-beta.0:
dependencies:
whatwg-url: 7.1.0
space-separated-tokens@1.1.5: {}
space-separated-tokens@2.0.2: {}
@ -16054,10 +15698,6 @@ snapshots:
dependencies:
tldts: 7.0.17
tr46@1.0.1:
dependencies:
punycode: 2.3.1
tr46@6.0.0:
dependencies:
punycode: 2.3.1
@ -16491,8 +16131,6 @@ snapshots:
web-vitals@5.1.0: {}
webidl-conversions@4.0.2: {}
webidl-conversions@8.0.1: {}
webpack-sources@3.3.4: {}
@ -16544,12 +16182,6 @@ snapshots:
tr46: 6.0.0
webidl-conversions: 8.0.1
whatwg-url@7.1.0:
dependencies:
lodash.sortby: 4.7.0
tr46: 1.0.1
webidl-conversions: 4.0.2
which@2.0.2:
dependencies:
isexe: 2.0.0
@ -16561,18 +16193,6 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@8.1.0:
dependencies:
ansi-styles: 6.2.3
string-width: 4.2.3
strip-ansi: 7.2.0
wrap-ansi@9.0.2:
dependencies:
ansi-styles: 6.2.3

View File

@ -47,6 +47,7 @@ import { useInvalidateAllBuiltInTools } from './use-tools'
const NAME_SPACE = 'plugins'
const useInstalledPluginListKey = [NAME_SPACE, 'installedPluginList']
const useCheckInstalledKey = [NAME_SPACE, 'checkInstalled'] as const
export const useCheckInstalled = ({
pluginIds,
enabled,
@ -55,7 +56,7 @@ export const useCheckInstalled = ({
enabled: boolean
}) => {
return useQuery<{ plugins: PluginDetail[] }>({
queryKey: [NAME_SPACE, 'checkInstalled', pluginIds],
queryKey: [...useCheckInstalledKey, pluginIds],
queryFn: () => post<{ plugins: PluginDetail[] }>('/workspaces/current/plugin/list/installations/ids', {
body: {
plugin_ids: pluginIds,
@ -66,6 +67,17 @@ export const useCheckInstalled = ({
})
}
export const useInvalidateCheckInstalled = () => {
const queryClient = useQueryClient()
return () => {
queryClient.invalidateQueries(
{
queryKey: useCheckInstalledKey,
},
)
}
}
const useRecommendedMarketplacePluginsKey = [NAME_SPACE, 'recommendedMarketplacePlugins']
export const useRecommendedMarketplacePlugins = ({
collection = '__recommended-plugins-tools',