mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
Merge branch 'feat/model-plugins-implementing' into deploy/dev
# Conflicts: # web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx # web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.tsx
This commit is contained in:
@ -0,0 +1,101 @@
|
||||
import type { ModelItem, ModelProvider } from '../declarations'
|
||||
import type { CredentialPanelState } from '../provider-added-card/use-credential-panel-state'
|
||||
import { ModelStatusEnum } from '../declarations'
|
||||
import { deriveTriggerStatus } from './derive-trigger-status'
|
||||
|
||||
const baseCredentialState: CredentialPanelState = {
|
||||
variant: 'api-active',
|
||||
priority: 'apiKey',
|
||||
supportsCredits: true,
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: true,
|
||||
isCreditsExhausted: false,
|
||||
credentialName: 'Primary Key',
|
||||
credits: 10,
|
||||
}
|
||||
|
||||
const mockProvider = { provider: 'openai' } as ModelProvider
|
||||
const mockModel = { model: 'gpt-4', status: ModelStatusEnum.active } as ModelItem
|
||||
|
||||
describe('deriveTriggerStatus', () => {
|
||||
it('returns empty when modelId is missing', () => {
|
||||
expect(deriveTriggerStatus(undefined, 'openai', mockProvider, mockModel, baseCredentialState)).toBe('empty')
|
||||
})
|
||||
|
||||
it('returns empty when providerName is missing', () => {
|
||||
expect(deriveTriggerStatus('gpt-4', undefined, mockProvider, mockModel, baseCredentialState)).toBe('empty')
|
||||
})
|
||||
|
||||
it('returns incompatible when provider plugin is not installed', () => {
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', undefined, mockModel, baseCredentialState)).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('returns credits-exhausted when credits priority and exhausted', () => {
|
||||
const state: CredentialPanelState = {
|
||||
...baseCredentialState,
|
||||
priority: 'credits',
|
||||
isCreditsExhausted: true,
|
||||
}
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('credits-exhausted')
|
||||
})
|
||||
|
||||
it('returns active when credits priority but not exhausted', () => {
|
||||
const state: CredentialPanelState = {
|
||||
...baseCredentialState,
|
||||
priority: 'credits',
|
||||
isCreditsExhausted: false,
|
||||
}
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('active')
|
||||
})
|
||||
|
||||
it('returns api-key-unavailable when variant is api-unavailable', () => {
|
||||
const state: CredentialPanelState = {
|
||||
...baseCredentialState,
|
||||
variant: 'api-unavailable',
|
||||
}
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('api-key-unavailable')
|
||||
})
|
||||
|
||||
it('returns incompatible when currentModel is missing (deprecated)', () => {
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, undefined, baseCredentialState)).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('returns incompatible when model status is not active', () => {
|
||||
const model = { ...mockModel, status: ModelStatusEnum.noConfigure } as ModelItem
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, model, baseCredentialState)).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('returns incompatible when model status is noPermission', () => {
|
||||
const model = { ...mockModel, status: ModelStatusEnum.noPermission } as ModelItem
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, model, baseCredentialState)).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('returns incompatible when model status is disabled', () => {
|
||||
const model = { ...mockModel, status: ModelStatusEnum.disabled } as ModelItem
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, model, baseCredentialState)).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('returns active when all conditions are satisfied', () => {
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, baseCredentialState)).toBe('active')
|
||||
})
|
||||
|
||||
it('prioritises credits-exhausted over api-unavailable', () => {
|
||||
const state: CredentialPanelState = {
|
||||
...baseCredentialState,
|
||||
priority: 'credits',
|
||||
isCreditsExhausted: true,
|
||||
variant: 'api-unavailable',
|
||||
}
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('credits-exhausted')
|
||||
})
|
||||
|
||||
it('does not return credits-exhausted when supportsCredits is false', () => {
|
||||
const state: CredentialPanelState = {
|
||||
...baseCredentialState,
|
||||
priority: 'credits',
|
||||
isCreditsExhausted: true,
|
||||
supportsCredits: false,
|
||||
}
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('active')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,53 @@
|
||||
import type { ModelItem, ModelProvider } from '../declarations'
|
||||
import type { CredentialPanelState } from '../provider-added-card/use-credential-panel-state'
|
||||
import { ModelStatusEnum } from '../declarations'
|
||||
|
||||
export type TriggerStatus
|
||||
= | 'empty'
|
||||
| 'active'
|
||||
| 'credits-exhausted'
|
||||
| 'api-key-unavailable'
|
||||
| 'incompatible'
|
||||
|
||||
export function deriveTriggerStatus(
|
||||
modelId: string | undefined,
|
||||
providerName: string | undefined,
|
||||
currentModelProvider: ModelProvider | undefined,
|
||||
currentModel: ModelItem | undefined,
|
||||
credentialState: CredentialPanelState,
|
||||
): TriggerStatus {
|
||||
if (!modelId || !providerName)
|
||||
return 'empty'
|
||||
|
||||
if (!currentModelProvider)
|
||||
return 'incompatible'
|
||||
|
||||
if (credentialState.priority === 'credits'
|
||||
&& credentialState.supportsCredits
|
||||
&& credentialState.isCreditsExhausted) {
|
||||
return 'credits-exhausted'
|
||||
}
|
||||
|
||||
if (credentialState.variant === 'api-unavailable')
|
||||
return 'api-key-unavailable'
|
||||
|
||||
if (!currentModel)
|
||||
return 'incompatible'
|
||||
|
||||
if (currentModel.status !== ModelStatusEnum.active)
|
||||
return 'incompatible'
|
||||
|
||||
return 'active'
|
||||
}
|
||||
|
||||
export const TRIGGER_STATUS_BADGE_I18N: Partial<Record<TriggerStatus, string>> = {
|
||||
'credits-exhausted': 'modelProvider.selector.creditsExhausted',
|
||||
'api-key-unavailable': 'modelProvider.selector.apiKeyUnavailable',
|
||||
'incompatible': 'modelProvider.selector.incompatible',
|
||||
}
|
||||
|
||||
export const TRIGGER_STATUS_TOOLTIP_I18N: Partial<Record<TriggerStatus, string>> = {
|
||||
'credits-exhausted': 'modelProvider.selector.creditsExhaustedTip',
|
||||
'api-key-unavailable': 'modelProvider.selector.apiKeyUnavailableTip',
|
||||
'incompatible': 'modelProvider.selector.incompatibleTip',
|
||||
}
|
||||
@ -49,7 +49,7 @@ vi.mock('@/service/use-common', () => ({
|
||||
data: {
|
||||
data: parameterRules,
|
||||
},
|
||||
isPending: isRulesLoading,
|
||||
isLoading: isRulesLoading,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -91,7 +91,7 @@ vi.mock('./presets-parameter', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./trigger', () => ({
|
||||
default: () => <button>Open Settings</button>,
|
||||
default: () => <div data-testid="trigger-mock">Open Settings</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
|
||||
@ -9,20 +9,19 @@ import type {
|
||||
} from '../declarations'
|
||||
import type { ParameterValue } from './parameter-item'
|
||||
import type { TriggerProps } from './trigger'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
Popover,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useModelParameterRules } from '@/service/use-common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { ModelStatusEnum } from '../declarations'
|
||||
import {
|
||||
useTextGenerationCurrentProviderAndModelAndModelList,
|
||||
} from '../hooks'
|
||||
@ -66,8 +65,8 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
isInWorkflow,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { isAPIKeySet } = useProviderContext()
|
||||
const [open, setOpen] = useState(false)
|
||||
const settingsIconRef = useRef<HTMLDivElement>(null)
|
||||
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId)
|
||||
const {
|
||||
currentProvider,
|
||||
@ -77,10 +76,6 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
{ provider, model: modelId },
|
||||
)
|
||||
|
||||
const hasDeprecated = !currentProvider || !currentModel
|
||||
const modelDisabled = currentModel?.status !== ModelStatusEnum.active
|
||||
const disabled = !isAPIKeySet || hasDeprecated || modelDisabled
|
||||
|
||||
const parameterRules: ModelParameterRule[] = useMemo(() => {
|
||||
return parameterRulesData?.data || []
|
||||
}, [parameterRulesData])
|
||||
@ -139,14 +134,11 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className="block">
|
||||
<button type="button" className="block w-full border-none bg-transparent p-0 text-left [color:inherit] [font:inherit]">
|
||||
{
|
||||
renderTrigger
|
||||
? renderTrigger({
|
||||
open,
|
||||
disabled,
|
||||
modelDisabled,
|
||||
hasDeprecated,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
providerName: provider,
|
||||
@ -154,31 +146,35 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
})
|
||||
: (
|
||||
<Trigger
|
||||
disabled={disabled}
|
||||
isInWorkflow={isInWorkflow}
|
||||
modelDisabled={modelDisabled}
|
||||
hasDeprecated={hasDeprecated}
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName={provider}
|
||||
modelId={modelId}
|
||||
settingsRef={settingsIconRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={isInWorkflow ? 'left' : 'bottom-end'}
|
||||
placement={isInWorkflow ? 'left' : (renderTrigger ? 'bottom-end' : 'left-start')}
|
||||
sideOffset={4}
|
||||
className={portalToFollowElemContentClassName}
|
||||
popupClassName={cn(popupClassName, 'w-[389px] rounded-2xl')}
|
||||
popupClassName={cn(popupClassName, 'w-[400px] rounded-2xl')}
|
||||
positionerProps={!renderTrigger ? { anchor: settingsIconRef } : undefined}
|
||||
>
|
||||
<div className="max-h-[420px] overflow-y-auto p-4 pt-3">
|
||||
<div className="relative">
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">
|
||||
{t('modelProvider.model', { ns: 'common' }).toLocaleUpperCase()}
|
||||
</div>
|
||||
<div className="relative px-3 pb-1 pt-3.5">
|
||||
<div className="pl-1 pr-8 text-text-primary system-xl-semibold">
|
||||
{t('modelProvider.modelSettings', { ns: 'common' })}
|
||||
</div>
|
||||
<PopoverClose className="absolute right-2.5 top-2.5 flex items-center justify-center rounded-lg p-1.5 hover:bg-state-base-hover">
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</PopoverClose>
|
||||
</div>
|
||||
<div className="max-h-[420px] overflow-y-auto">
|
||||
<div className="px-4 pb-4 pt-2">
|
||||
<ModelSelector
|
||||
defaultModel={(provider || modelId) ? { provider, model: modelId } : undefined}
|
||||
modelList={activeTextGenerationModelList}
|
||||
@ -188,41 +184,40 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
</div>
|
||||
{
|
||||
!!parameterRules.length && (
|
||||
<div className="my-3 h-px bg-divider-subtle" />
|
||||
)
|
||||
}
|
||||
{
|
||||
isLoading && (
|
||||
<div className="mt-5"><Loading /></div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && !!parameterRules.length && (
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex h-6 items-center text-text-secondary system-sm-semibold">{t('modelProvider.parameters', { ns: 'common' })}</div>
|
||||
<div className="flex flex-col gap-2 border-t border-divider-subtle px-4 pb-4 pt-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex flex-1 items-center text-text-secondary system-sm-semibold-uppercase">{t('modelProvider.parameters', { ns: 'common' })}</div>
|
||||
{
|
||||
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
|
||||
<PresetsParameter onSelect={handleSelectPresetParameter} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
|
||||
<PresetsParameter onSelect={handleSelectPresetParameter} />
|
||||
)
|
||||
isLoading
|
||||
? <div className="py-5"><Loading /></div>
|
||||
: (
|
||||
[
|
||||
...parameterRules,
|
||||
...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []),
|
||||
].map(parameter => (
|
||||
<ParameterItem
|
||||
key={`${modelId}-${parameter.name}`}
|
||||
parameterRule={parameter}
|
||||
value={completionParams?.[parameter.name]}
|
||||
onChange={v => handleParamChange(parameter.name, v)}
|
||||
onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)}
|
||||
isInWorkflow={isInWorkflow}
|
||||
/>
|
||||
))
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && !!parameterRules.length && (
|
||||
[
|
||||
...parameterRules,
|
||||
...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []),
|
||||
].map(parameter => (
|
||||
<ParameterItem
|
||||
key={`${modelId}-${parameter.name}`}
|
||||
parameterRule={parameter}
|
||||
value={completionParams?.[parameter.name]}
|
||||
onChange={v => handleParamChange(parameter.name, v)}
|
||||
onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)}
|
||||
isInWorkflow={isInWorkflow}
|
||||
/>
|
||||
))
|
||||
!parameterRules.length && isLoading && (
|
||||
<div className="px-4 py-5"><Loading /></div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -109,7 +109,7 @@ describe('ParameterItem', () => {
|
||||
|
||||
it('should render select for string with options', () => {
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'string', options: ['a', 'b'] })} value="a" />)
|
||||
// SimpleSelect renders an element with text 'a'
|
||||
// Select renders the selected value in the trigger
|
||||
expect(screen.getByText('a')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ModelParameterRule } from '../declarations'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import TagInput from '@/app/components/base/tag-input'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useLanguage } from '../hooks'
|
||||
import { isNullOrUndefined } from '../utils'
|
||||
@ -20,13 +19,13 @@ type ParameterItemProps = {
|
||||
onSwitch?: (checked: boolean, assignValue: ParameterValue) => void
|
||||
isInWorkflow?: boolean
|
||||
}
|
||||
const ParameterItem: FC<ParameterItemProps> = ({
|
||||
function ParameterItem({
|
||||
parameterRule,
|
||||
value,
|
||||
onChange,
|
||||
onSwitch,
|
||||
isInWorkflow,
|
||||
}) => {
|
||||
}: ParameterItemProps) {
|
||||
const language = useLanguage()
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const numberInputRef = useRef<HTMLInputElement>(null)
|
||||
@ -99,10 +98,6 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
handleInputChange(e.target.value)
|
||||
}
|
||||
|
||||
const handleSelect = (option: { value: string | number, name: string }) => {
|
||||
handleInputChange(option.value)
|
||||
}
|
||||
|
||||
const handleTagChange = (newSequences: string[]) => {
|
||||
handleInputChange(newSequences)
|
||||
}
|
||||
@ -222,13 +217,19 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
|
||||
if (parameterRule.type === 'string' && !!parameterRule?.options?.length) {
|
||||
return (
|
||||
<SimpleSelect
|
||||
className="!py-0"
|
||||
wrapperClassName={cn('!h-8 w-full')}
|
||||
defaultValue={renderValue as string}
|
||||
onSelect={handleSelect}
|
||||
items={parameterRule.options.map(option => ({ value: option, name: option }))}
|
||||
/>
|
||||
<Select
|
||||
value={renderValue as string}
|
||||
onValueChange={v => handleInputChange(v ?? undefined)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parameterRule.options!.map(option => (
|
||||
<SelectItem key={option} value={option}>{option}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
@ -272,13 +273,18 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
</div>
|
||||
{
|
||||
parameterRule.help && (
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="mr-1 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" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent popupClassName="mr-1">
|
||||
<div className="w-[150px] whitespace-pre-wrap">{parameterRule.help[language] || parameterRule.help.en_US}</div>
|
||||
)}
|
||||
popupClassName="mr-1"
|
||||
triggerClassName="mr-1 w-4 h-4 shrink-0"
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -18,13 +18,12 @@ describe('PresetsParameter', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
// open=true: trigger has bg-state-base-hover class
|
||||
it('should apply hover background class when open is true', () => {
|
||||
it('should mark trigger as open when dropdown is expanded', () => {
|
||||
render(<PresetsParameter onSelect={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }))
|
||||
|
||||
const button = screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })
|
||||
expect(button).toHaveClass('bg-state-base-hover')
|
||||
expect(button).toHaveAttribute('data-popup-open')
|
||||
})
|
||||
|
||||
// Tone map branch 2: Balanced → Scales02 icon
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
import type { FC } from 'react'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useCallback } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Dropdown from '@/app/components/base/dropdown'
|
||||
import { Brush01 } from '@/app/components/base/icons/src/vender/solid/editor'
|
||||
import { Scales02 } from '@/app/components/base/icons/src/vender/solid/FinanceAndECommerce'
|
||||
import { Target04 } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { TONE_LIST } from '@/config'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const toneI18nKeyMap = {
|
||||
Creative: 'model.tone.Creative',
|
||||
@ -17,53 +19,42 @@ const toneI18nKeyMap = {
|
||||
Custom: 'model.tone.Custom',
|
||||
} as const
|
||||
|
||||
const TONE_ICONS: Record<number, ReactNode> = {
|
||||
1: <Brush01 className="mr-2 h-[14px] w-[14px] text-[#6938EF]" />,
|
||||
2: <Scales02 className="mr-2 h-[14px] w-[14px] text-indigo-600" />,
|
||||
3: <Target04 className="mr-2 h-[14px] w-[14px] text-[#107569]" />,
|
||||
}
|
||||
|
||||
type PresetsParameterProps = {
|
||||
onSelect: (toneId: number) => void
|
||||
}
|
||||
const PresetsParameter: FC<PresetsParameterProps> = ({
|
||||
onSelect,
|
||||
}) => {
|
||||
|
||||
function PresetsParameter({ onSelect }: PresetsParameterProps) {
|
||||
const { t } = useTranslation()
|
||||
const renderTrigger = useCallback((open: boolean) => {
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
className={cn(open && 'bg-state-base-hover')}
|
||||
>
|
||||
{t('modelProvider.loadPresets', { ns: 'common' })}
|
||||
<RiArrowDownSLine className="ml-0.5 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)
|
||||
}, [t])
|
||||
const getToneIcon = (toneId: number) => {
|
||||
const className = 'mr-2 h-[14px] w-[14px]'
|
||||
const res = ({
|
||||
1: <Brush01 className={`${className} text-[#6938EF]`} />,
|
||||
2: <Scales02 className={`${className} text-indigo-600`} />,
|
||||
3: <Target04 className={`${className} text-[#107569]`} />,
|
||||
})[toneId]
|
||||
return res
|
||||
}
|
||||
const options = TONE_LIST.slice(0, 3).map((tone) => {
|
||||
return {
|
||||
value: tone.id,
|
||||
text: (
|
||||
<div className="flex h-full items-center">
|
||||
{getToneIcon(tone.id)}
|
||||
{t(toneI18nKeyMap[tone.name], { ns: 'common' })}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
renderTrigger={renderTrigger}
|
||||
items={options}
|
||||
onSelect={item => onSelect(item.value as number)}
|
||||
popupClassName="z-[1003]"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
className="data-[popup-open]:bg-state-base-hover"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{t('modelProvider.loadPresets', { ns: 'common' })}
|
||||
<span className="i-ri-arrow-down-s-line ml-0.5 h-3.5 w-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{TONE_LIST.slice(0, 3).map(tone => (
|
||||
<DropdownMenuItem key={tone.id} onClick={() => onSelect(tone.id)}>
|
||||
{TONE_ICONS[tone.id]}
|
||||
{t(toneI18nKeyMap[tone.name], { ns: 'common' })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -26,9 +26,34 @@ vi.mock('../model-icon', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../model-name', () => ({
|
||||
default: ({ modelItem }: { modelItem: { model: string } }) => <div>{modelItem.model}</div>,
|
||||
default: ({
|
||||
modelItem,
|
||||
showMode,
|
||||
showFeatures,
|
||||
}: {
|
||||
modelItem: { model: string }
|
||||
showMode?: boolean
|
||||
showFeatures?: boolean
|
||||
}) => (
|
||||
<div>
|
||||
<span>{modelItem.model}</span>
|
||||
{showMode && <span data-testid="model-name-mode">mode</span>}
|
||||
{showFeatures && <span data-testid="model-name-features">features</span>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const activeCredentialState = {
|
||||
variant: 'api-active' as const,
|
||||
supportsCredits: true,
|
||||
isCreditsExhausted: false,
|
||||
priority: 'apiKey' as const,
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: true,
|
||||
credentialName: 'Primary Key',
|
||||
credits: 10,
|
||||
}
|
||||
|
||||
describe('Trigger', () => {
|
||||
const currentProvider = {
|
||||
provider: 'openai',
|
||||
@ -42,20 +67,11 @@ describe('Trigger', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
variant: 'api-active',
|
||||
supportsCredits: true,
|
||||
isCreditsExhausted: false,
|
||||
priority: 'apiKey',
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: true,
|
||||
credentialName: 'Primary Key',
|
||||
credits: 10,
|
||||
})
|
||||
mockUseCredentialPanelState.mockReturnValue(activeCredentialState)
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render initialized state when provider and model are available', () => {
|
||||
it('should render active state with model features in non-workflow mode', () => {
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
@ -67,6 +83,8 @@ describe('Trigger', () => {
|
||||
|
||||
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-name-mode')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-name-features')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render fallback model id when current model is missing', () => {
|
||||
@ -80,7 +98,7 @@ describe('Trigger', () => {
|
||||
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workflow styles when workflow mode is enabled', () => {
|
||||
it('should render split layout with workflow styles when workflow mode is enabled', () => {
|
||||
const { container } = render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
@ -91,30 +109,35 @@ describe('Trigger', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toHaveClass('border-workflow-block-parma-bg')
|
||||
expect(container.firstChild).toHaveClass('bg-workflow-block-parma-bg')
|
||||
const leftPanel = container.querySelector('.rounded-l-lg')
|
||||
expect(leftPanel).toBeInTheDocument()
|
||||
expect(leftPanel).toHaveClass('border-workflow-block-parma-bg')
|
||||
const rightPanel = container.querySelector('.rounded-r-lg')
|
||||
expect(rightPanel).toBeInTheDocument()
|
||||
expect(rightPanel).toHaveClass('border-workflow-block-parma-bg')
|
||||
})
|
||||
|
||||
it('should render workflow empty state when no provider or model is selected', () => {
|
||||
const { container } = render(<Trigger isInWorkflow />)
|
||||
it('should render empty state when no provider or model is selected', () => {
|
||||
render(<Trigger isInWorkflow />)
|
||||
|
||||
expect(screen.getByText('workflow:errorMsg.configureModel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render non-workflow empty state with warning border', () => {
|
||||
const { container } = render(<Trigger />)
|
||||
|
||||
expect(screen.getByText('workflow:errorMsg.configureModel')).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveClass('border-text-warning')
|
||||
expect(container.firstChild).toHaveClass('bg-state-warning-hover')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Status badges', () => {
|
||||
it('should render credits exhausted split layout in non-workflow mode', () => {
|
||||
it('should render credits exhausted badge in non-workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'credits-exhausted',
|
||||
supportsCredits: true,
|
||||
isCreditsExhausted: true,
|
||||
priority: 'credits',
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: false,
|
||||
credentialName: undefined,
|
||||
credits: 0,
|
||||
})
|
||||
|
||||
render(
|
||||
@ -127,43 +150,14 @@ describe('Trigger', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('model-name-mode')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('model-name-features')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should resolve provider from context when currentProvider is missing in split layout', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
variant: 'credits-exhausted',
|
||||
supportsCredits: true,
|
||||
isCreditsExhausted: true,
|
||||
priority: 'credits',
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: false,
|
||||
credentialName: undefined,
|
||||
credits: 0,
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render api unavailable split layout in non-workflow mode', () => {
|
||||
it('should render api unavailable badge in non-workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'api-unavailable',
|
||||
supportsCredits: true,
|
||||
isCreditsExhausted: false,
|
||||
priority: 'apiKey',
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: true,
|
||||
credentialName: 'Primary Key',
|
||||
credits: 0,
|
||||
})
|
||||
|
||||
render(
|
||||
@ -178,13 +172,50 @@ describe('Trigger', () => {
|
||||
expect(screen.getByText('common.modelProvider.selector.apiKeyUnavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render incompatible badge when deprecated model is disabled', () => {
|
||||
it('should render credits exhausted badge in workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'credits-exhausted',
|
||||
isCreditsExhausted: true,
|
||||
priority: 'credits',
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
disabled
|
||||
hasDeprecated
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
isInWorkflow
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render api unavailable badge in workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'api-unavailable',
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
isInWorkflow
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.apiKeyUnavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render incompatible badge when model is deprecated (currentModel missing)', () => {
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
@ -193,24 +224,20 @@ describe('Trigger', () => {
|
||||
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render warning icon when model status is disabled but not deprecated', () => {
|
||||
it('should render incompatible badge when model status is non-active', () => {
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={{ ...currentModel, status: 'no-configure' } as typeof currentModel}
|
||||
disabled
|
||||
modelDisabled
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(document.querySelector('.text-\\[\\#F79009\\]')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should render without crashing when providerName does not match any provider', () => {
|
||||
it('should render incompatible badge when provider plugin is not installed', () => {
|
||||
render(
|
||||
<Trigger
|
||||
modelId="gpt-4"
|
||||
@ -218,7 +245,44 @@ describe('Trigger', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Split layout', () => {
|
||||
it('should use split layout with settings button in non-workflow mode', () => {
|
||||
const { container } = render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
const splitContainer = container.querySelector('.rounded-l-lg')
|
||||
expect(splitContainer).toBeInTheDocument()
|
||||
const settingsButton = container.querySelector('.rounded-r-lg')
|
||||
expect(settingsButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use split layout for error states in non-workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'api-unavailable',
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
const splitContainer = container.querySelector('.rounded-l-lg')
|
||||
expect(splitContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,185 +1,128 @@
|
||||
import type { FC } from 'react'
|
||||
import type { FC, Ref } from 'react'
|
||||
import type {
|
||||
Model,
|
||||
ModelItem,
|
||||
ModelProvider,
|
||||
} from '../declarations'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
|
||||
import { SlidersH } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { MODEL_STATUS_TEXT } from '../declarations'
|
||||
import { useLanguage } from '../hooks'
|
||||
import ModelIcon from '../model-icon'
|
||||
import ModelName from '../model-name'
|
||||
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
|
||||
import {
|
||||
deriveTriggerStatus,
|
||||
TRIGGER_STATUS_BADGE_I18N,
|
||||
TRIGGER_STATUS_TOOLTIP_I18N,
|
||||
} from './derive-trigger-status'
|
||||
|
||||
export type TriggerProps = {
|
||||
open?: boolean
|
||||
disabled?: boolean
|
||||
currentProvider?: ModelProvider | Model
|
||||
currentModel?: ModelItem
|
||||
providerName?: string
|
||||
modelId?: string
|
||||
isInWorkflow?: boolean
|
||||
settingsRef?: Ref<HTMLDivElement>
|
||||
disabled?: boolean
|
||||
hasDeprecated?: boolean
|
||||
modelDisabled?: boolean
|
||||
isInWorkflow?: boolean
|
||||
}
|
||||
|
||||
const Trigger: FC<TriggerProps> = ({
|
||||
disabled,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
providerName,
|
||||
modelId,
|
||||
hasDeprecated,
|
||||
modelDisabled,
|
||||
isInWorkflow,
|
||||
settingsRef,
|
||||
disabled,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const { modelProviders } = useProviderContext()
|
||||
const isEmpty = !modelId || !providerName
|
||||
const currentModelProvider = modelProviders.find(p => p.provider === providerName)
|
||||
const state = useCredentialPanelState(currentModelProvider)
|
||||
const hasCredits = !state.isCreditsExhausted
|
||||
const showCreditsExhausted = !isEmpty && !hasCredits && state.supportsCredits
|
||||
const showApiKeyUnavailable = !isEmpty && state.variant === 'api-unavailable'
|
||||
const credentialState = useCredentialPanelState(currentModelProvider)
|
||||
|
||||
// Non-workflow status error: split layout with badge + settings button
|
||||
if ((showCreditsExhausted || showApiKeyUnavailable) && !isInWorkflow) {
|
||||
const derivedStatus = deriveTriggerStatus(modelId, providerName, currentModelProvider, currentModel, credentialState)
|
||||
// Backward compatibility: legacy callers used `disabled` as an incompatible signal.
|
||||
const status = disabled ? 'incompatible' : derivedStatus
|
||||
const badgeKey = TRIGGER_STATUS_BADGE_I18N[status]
|
||||
const tooltipKey = TRIGGER_STATUS_TOOLTIP_I18N[status]
|
||||
const isActive = status === 'active'
|
||||
const iconProvider = currentProvider || modelProviders.find(item => item.provider === providerName)
|
||||
|
||||
if (status === 'empty') {
|
||||
return (
|
||||
<div className="flex h-8 min-w-[296px] cursor-pointer items-center gap-px overflow-hidden rounded-lg">
|
||||
<div className="flex flex-1 items-center gap-0.5 rounded-l-lg bg-components-input-bg-normal p-1">
|
||||
<ModelIcon
|
||||
className="p-0.5"
|
||||
provider={currentProvider || modelProviders.find(item => item.provider === providerName)}
|
||||
modelName={currentModel?.model}
|
||||
/>
|
||||
<div className="flex flex-1 items-center truncate px-1 py-[3px]">
|
||||
{currentModel
|
||||
? <ModelName className="grow" modelItem={currentModel} showMode showFeatures />
|
||||
: <div className="truncate text-[13px] font-normal text-components-input-text-filled">{modelId}</div>}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center pr-0.5">
|
||||
<div className="flex min-w-[20px] shrink-0 items-center justify-center gap-[3px] rounded-md border border-text-warning bg-components-badge-bg-dimm px-[5px] py-0.5">
|
||||
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
|
||||
<span className="whitespace-nowrap text-text-warning system-xs-medium">
|
||||
{t(showCreditsExhausted ? 'modelProvider.selector.creditsExhausted' : 'modelProvider.selector.apiKeyUnavailable', { ns: 'common' })}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-8 min-w-[296px] cursor-pointer items-center rounded-lg px-2',
|
||||
isInWorkflow
|
||||
? 'border border-text-warning bg-state-warning-hover pr-[30px]'
|
||||
: 'border border-text-warning bg-state-warning-hover ring-inset ring-text-warning hover:ring-[0.5px]',
|
||||
)}
|
||||
>
|
||||
<div className="mr-2 flex h-6 w-6 shrink-0 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 shrink-0 items-center justify-center rounded-r-lg bg-components-button-tertiary-bg p-2">
|
||||
<SlidersH className="h-4 w-4 text-text-tertiary" />
|
||||
<div className="mr-1 flex-1 truncate text-[13px] font-normal text-text-secondary">
|
||||
{t('workflow:errorMsg.configureModel')}
|
||||
</div>
|
||||
<span className={cn('i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary', isInWorkflow && 'absolute right-2 top-[9px] h-3.5 w-3.5')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-8 cursor-pointer items-center rounded-lg px-2',
|
||||
!isInWorkflow && 'min-w-[296px]',
|
||||
!isInWorkflow && 'border ring-inset hover:ring-[0.5px]',
|
||||
!isInWorkflow && (disabled ? 'border-text-warning bg-state-warning-hover ring-text-warning' : 'border-util-colors-indigo-indigo-600 bg-state-accent-hover ring-util-colors-indigo-indigo-600'),
|
||||
isInWorkflow && 'pr-[30px]',
|
||||
isInWorkflow && !isEmpty && 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg hover:border-components-input-border-active',
|
||||
isInWorkflow && isEmpty && 'border border-text-warning bg-state-warning-hover',
|
||||
)}
|
||||
>
|
||||
{
|
||||
isEmpty && (
|
||||
<div className="mr-2 flex h-6 w-6 shrink-0 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>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isEmpty && currentProvider && (
|
||||
<ModelIcon
|
||||
className="mr-1.5 !h-5 !w-5"
|
||||
provider={currentProvider}
|
||||
modelName={currentModel?.model}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isEmpty && !currentProvider && (
|
||||
<ModelIcon
|
||||
className="mr-1.5 !h-5 !w-5"
|
||||
provider={modelProviders.find(item => item.provider === providerName)}
|
||||
modelName={modelId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isEmpty && currentModel && (
|
||||
<ModelName
|
||||
className="mr-1.5 text-text-primary"
|
||||
modelItem={currentModel}
|
||||
showMode
|
||||
showFeatures
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isEmpty && !currentModel && (
|
||||
<div className="mr-1 truncate text-[13px] font-medium text-text-primary">
|
||||
{modelId}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
isEmpty && (
|
||||
<div className="mr-1 flex-1 truncate text-[13px] font-normal text-text-secondary">
|
||||
{t('workflow:errorMsg.configureModel')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isEmpty && (
|
||||
disabled
|
||||
<div className="flex h-8 min-w-[296px] cursor-pointer items-center gap-px overflow-hidden rounded-lg">
|
||||
<div className={cn('flex flex-1 items-center gap-0.5 rounded-l-lg p-1', isInWorkflow ? 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg' : 'bg-components-input-bg-normal')}>
|
||||
<ModelIcon
|
||||
className="p-0.5"
|
||||
provider={iconProvider}
|
||||
modelName={currentModel?.model || modelId}
|
||||
/>
|
||||
<div className="flex flex-1 items-center truncate px-1 py-[3px]">
|
||||
{currentModel
|
||||
? (
|
||||
hasDeprecated
|
||||
? (
|
||||
<Tooltip popupContent={t('modelProvider.selector.incompatibleTip', { ns: 'common' })}>
|
||||
<div className="ml-auto flex min-w-[20px] shrink-0 items-center justify-center gap-[3px] rounded-md border border-text-warning bg-components-badge-bg-dimm px-[5px] py-0.5">
|
||||
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
|
||||
<span className="whitespace-nowrap text-text-warning system-xs-medium">
|
||||
{t('modelProvider.selector.incompatible', { ns: 'common' })}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
(modelDisabled && currentModel)
|
||||
? MODEL_STATUS_TEXT[currentModel.status as string][language]
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-[#F79009]" />
|
||||
</Tooltip>
|
||||
)
|
||||
<ModelName
|
||||
className="grow"
|
||||
modelItem={currentModel}
|
||||
showMode={isActive}
|
||||
showFeatures={isActive}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<SlidersH className={cn(!isInWorkflow ? 'text-indigo-600' : 'text-text-tertiary', 'h-4 w-4 shrink-0')} />
|
||||
)
|
||||
)
|
||||
}
|
||||
{
|
||||
isEmpty && (
|
||||
<RiArrowDownSLine className={cn('h-4 w-4 shrink-0 text-text-tertiary', isInWorkflow && 'absolute right-2 top-[9px] h-3.5 w-3.5')} />
|
||||
)
|
||||
}
|
||||
{!isEmpty && isInWorkflow && (<RiArrowDownSLine className="absolute right-2 top-[9px] h-3.5 w-3.5 text-text-tertiary" />)}
|
||||
: <div className="truncate text-[13px] font-normal text-components-input-text-filled">{modelId}</div>}
|
||||
</div>
|
||||
{badgeKey && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="flex shrink-0 items-center pr-0.5">
|
||||
<div className="flex min-w-[20px] shrink-0 items-center justify-center gap-[3px] rounded-md border border-text-warning bg-components-badge-bg-dimm px-[5px] py-0.5">
|
||||
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
|
||||
<span className="whitespace-nowrap text-text-warning system-xs-medium">
|
||||
{t(badgeKey as 'modelProvider.selector.incompatible', { ns: 'common' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent placement="top">
|
||||
{t(tooltipKey as 'modelProvider.selector.incompatibleTip', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!badgeKey && (
|
||||
<div className="flex shrink-0 items-center pr-1">
|
||||
<span className="i-ri-arrow-down-s-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={settingsRef} className={cn('flex shrink-0 items-center justify-center rounded-r-lg p-2', isInWorkflow ? 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg' : 'bg-components-button-tertiary-bg')}>
|
||||
<span className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -166,6 +166,30 @@ describe('ModelSelectorTrigger', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.creditsExhausted').parentElement).toHaveClass('bg-components-badge-bg-dimm')
|
||||
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide model meta when api key is unavailable', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
variant: 'api-unavailable',
|
||||
priority: 'apiKey',
|
||||
supportsCredits: true,
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: true,
|
||||
isCreditsExhausted: false,
|
||||
credentialName: 'Primary Key',
|
||||
credits: 0,
|
||||
})
|
||||
|
||||
render(
|
||||
<ModelSelectorTrigger
|
||||
currentProvider={createModel()}
|
||||
currentModel={createModelItem()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.apiKeyUnavailable')).toBeInTheDocument()
|
||||
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show status badge when selected model is readonly', () => {
|
||||
@ -189,6 +213,7 @@ describe('ModelSelectorTrigger', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
|
||||
await user.hover(screen.getByText('common.modelProvider.selector.incompatible'))
|
||||
|
||||
expect(await screen.findByText('common.modelProvider.selector.incompatibleTip')).toBeInTheDocument()
|
||||
@ -196,20 +221,18 @@ describe('ModelSelectorTrigger', () => {
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should show deprecated tooltip when hovering warn icon', async () => {
|
||||
it('should show incompatible badge for deprecated selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(
|
||||
render(
|
||||
<ModelSelectorTrigger
|
||||
defaultModel={{ provider: 'openai', model: 'legacy-model' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const warnIcon = container.querySelector('.i-ri-alert-line')
|
||||
expect(warnIcon).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
|
||||
await user.hover(screen.getByText('common.modelProvider.selector.incompatible'))
|
||||
|
||||
await user.hover(warnIcon as HTMLElement)
|
||||
|
||||
expect(await screen.findByText('common.modelProvider.deprecated')).toBeInTheDocument()
|
||||
expect(await screen.findByText('common.modelProvider.selector.incompatibleTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render fallback icon when deprecated provider is not found', () => {
|
||||
|
||||
@ -12,14 +12,7 @@ import { ModelStatusEnum } from '../declarations'
|
||||
import ModelIcon from '../model-icon'
|
||||
import ModelName from '../model-name'
|
||||
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
|
||||
|
||||
const STATUS_I18N_KEY: Partial<Record<ModelStatusEnum, string>> = {
|
||||
[ModelStatusEnum.quotaExceeded]: 'modelProvider.selector.creditsExhausted',
|
||||
[ModelStatusEnum.noConfigure]: 'modelProvider.selector.configureRequired',
|
||||
[ModelStatusEnum.noPermission]: 'modelProvider.selector.incompatible',
|
||||
[ModelStatusEnum.disabled]: 'modelProvider.selector.disabled',
|
||||
[ModelStatusEnum.credentialRemoved]: 'modelProvider.selector.apiKeyUnavailable',
|
||||
}
|
||||
import { MODEL_STATUS_I18N_KEY } from '../status-mapping'
|
||||
|
||||
type ModelSelectorTriggerProps = {
|
||||
currentProvider?: Model
|
||||
@ -56,14 +49,18 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
|
||||
&& selectedProviderState.priority === 'credits'
|
||||
&& selectedProviderState.supportsCredits
|
||||
&& selectedProviderState.isCreditsExhausted
|
||||
const shouldShowApiKeyUnavailable = isSelected && selectedProviderState.variant === 'api-unavailable'
|
||||
const effectiveStatus = shouldShowCreditsExhausted
|
||||
? ModelStatusEnum.quotaExceeded
|
||||
: currentModel?.status
|
||||
: shouldShowApiKeyUnavailable
|
||||
? ModelStatusEnum.credentialRemoved
|
||||
: currentModel?.status
|
||||
|
||||
const isActive = isSelected && effectiveStatus === ModelStatusEnum.active
|
||||
const isDisabled = isDeprecated || (isSelected && !isActive)
|
||||
const statusI18nKey = isSelected && effectiveStatus ? STATUS_I18N_KEY[effectiveStatus] : undefined
|
||||
const statusI18nKey = isSelected && effectiveStatus ? MODEL_STATUS_I18N_KEY[effectiveStatus] : undefined
|
||||
const isCreditsExhausted = isSelected && effectiveStatus === ModelStatusEnum.quotaExceeded
|
||||
const shouldShowModelMeta = effectiveStatus === ModelStatusEnum.active
|
||||
|
||||
const deprecatedProvider = isDeprecated
|
||||
? modelProviders.find(p => p.provider === defaultModel.provider)
|
||||
@ -102,8 +99,8 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
|
||||
<ModelName
|
||||
className="grow"
|
||||
modelItem={currentModel}
|
||||
showMode
|
||||
showFeatures
|
||||
showMode={shouldShowModelMeta}
|
||||
showFeatures={shouldShowModelMeta}
|
||||
/>
|
||||
)}
|
||||
{isDeprecated && (
|
||||
@ -143,12 +140,18 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
|
||||
|
||||
{isDeprecated && showDeprecatedWarnIcon && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={(
|
||||
<span className="i-ri-alert-line h-4 w-4 shrink-0 text-text-warning-secondary" />
|
||||
)}
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="flex shrink-0 items-center gap-[3px] rounded-md border border-text-warning bg-components-badge-bg-dimm px-[5px] py-0.5">
|
||||
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
|
||||
<span className="whitespace-nowrap text-text-warning system-xs-medium">
|
||||
{t('modelProvider.selector.incompatible', { ns: 'common' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent placement="top">
|
||||
{t('modelProvider.deprecated', { ns: 'common' })}
|
||||
{t('modelProvider.selector.incompatibleTip', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@ -103,7 +103,7 @@ const PopupItem: FC<PopupItemProps> = ({
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<div className="flex h-[22px] items-center justify-between px-3 text-xs font-medium text-text-tertiary">
|
||||
<div className="sticky top-12 z-[2] flex h-[22px] items-center justify-between bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={() => setCollapsed(prev => !prev)}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Model, ModelItem } from '../declarations'
|
||||
import type { Model, ModelItem, ModelProvider } from '../declarations'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
@ -31,13 +31,29 @@ vi.mock('@/app/components/base/tooltip/TooltipManager', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const mockMarketplacePlugins = vi.hoisted(() => ({ current: [] as Array<{ plugin_id: string, latest_package_identifier: string }> }))
|
||||
type MockMarketplacePlugin = {
|
||||
plugin_id: string
|
||||
latest_package_identifier: string
|
||||
}
|
||||
|
||||
type MockContextProvider = Pick<ModelProvider, 'provider' | 'custom_configuration' | 'system_configuration'>
|
||||
|
||||
const mockMarketplacePlugins = vi.hoisted(() => ({
|
||||
current: [] as MockMarketplacePlugin[],
|
||||
isLoading: false,
|
||||
}))
|
||||
const mockContextModelProviders = vi.hoisted(() => ({
|
||||
current: [] as MockContextProvider[],
|
||||
}))
|
||||
vi.mock('../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useLanguage: () => mockLanguage,
|
||||
useMarketplaceAllPlugins: () => ({ plugins: mockMarketplacePlugins.current }),
|
||||
useMarketplaceAllPlugins: () => ({
|
||||
plugins: mockMarketplacePlugins.current,
|
||||
isLoading: mockMarketplacePlugins.isLoading,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@ -46,7 +62,7 @@ vi.mock('./popup-item', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({ modelProviders: [] }),
|
||||
useProviderContext: () => ({ modelProviders: mockContextModelProviders.current }),
|
||||
}))
|
||||
|
||||
vi.mock('../provider-added-card/use-trial-credits', () => ({
|
||||
@ -122,12 +138,25 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const makeContextProvider = (overrides: Partial<MockContextProvider> = {}): MockContextProvider => ({
|
||||
provider: 'test-openai',
|
||||
custom_configuration: {
|
||||
status: 'no-configure',
|
||||
} as MockContextProvider['custom_configuration'],
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
} as MockContextProvider['system_configuration'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Popup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLanguage = 'en_US'
|
||||
mockSupportFunctionCall.mockReturnValue(true)
|
||||
mockMarketplacePlugins.current = []
|
||||
mockMarketplacePlugins.isLoading = false
|
||||
mockContextModelProviders.current = []
|
||||
})
|
||||
|
||||
it('should filter models by search and allow clearing search', () => {
|
||||
@ -273,9 +302,11 @@ describe('Popup', () => {
|
||||
})
|
||||
|
||||
it('should render marketplace providers that are not installed', () => {
|
||||
mockContextModelProviders.current = [makeContextProvider({ provider: 'test-openai' })]
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[makeModel({ provider: 'test-openai' })]}
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
@ -287,6 +318,22 @@ describe('Popup', () => {
|
||||
expect(screen.getByText(/modelProvider\.selector\.discoverMoreInMarketplace/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide installed marketplace providers when they are absent from the current modelList', () => {
|
||||
mockContextModelProviders.current = [makeContextProvider({ provider: 'test-anthropic' })]
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('test-anthropic')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('TestAnthropic')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle marketplace section collapse', () => {
|
||||
render(
|
||||
<Popup
|
||||
|
||||
@ -49,6 +49,7 @@ const Popup: FC<PopupProps> = ({
|
||||
const { modelProviders } = useProviderContext()
|
||||
const {
|
||||
plugins: allPlugins,
|
||||
isLoading: isMarketplacePluginsLoading,
|
||||
} = useMarketplaceAllPlugins(modelProviders, '')
|
||||
const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace()
|
||||
const { refreshPluginList } = useRefreshPluginList()
|
||||
@ -66,7 +67,7 @@ const Popup: FC<PopupProps> = ({
|
||||
}, [modelProviders])
|
||||
|
||||
const handleInstallPlugin = useCallback(async (key: ModelProviderQuotaGetPaid) => {
|
||||
if (!allPlugins || installingProvider)
|
||||
if (!allPlugins || isMarketplacePluginsLoading || installingProvider)
|
||||
return
|
||||
const pluginId = providerKeyToPluginId[key]
|
||||
const plugin = allPlugins.find(p => p.plugin_id === pluginId)
|
||||
@ -87,10 +88,30 @@ const Popup: FC<PopupProps> = ({
|
||||
finally {
|
||||
setInstallingProvider(null)
|
||||
}
|
||||
}, [allPlugins, installingProvider, installPackageFromMarketPlace, refreshPluginList])
|
||||
}, [allPlugins, installPackageFromMarketPlace, installingProvider, isMarketplacePluginsLoading, refreshPluginList])
|
||||
|
||||
const installedModelList = useMemo(() => {
|
||||
const modelMap = new Map(modelList.map(model => [model.provider, model]))
|
||||
const installedMarketplaceModels = MODEL_PROVIDER_QUOTA_GET_PAID.flatMap((providerKey) => {
|
||||
const installedProvider = modelProviders.find(provider => provider.provider === providerKey)
|
||||
|
||||
if (!installedProvider)
|
||||
return []
|
||||
|
||||
const matchedModel = modelMap.get(providerKey)
|
||||
return matchedModel ? [matchedModel] : []
|
||||
})
|
||||
const otherModels = modelList.filter(model => !MODEL_PROVIDER_QUOTA_GET_PAID.includes(model.provider as ModelProviderQuotaGetPaid))
|
||||
|
||||
return [...installedMarketplaceModels, ...otherModels]
|
||||
}, [modelList, modelProviders])
|
||||
|
||||
const filteredModelList = useMemo(() => {
|
||||
const filtered = modelList.map((model) => {
|
||||
const filtered = installedModelList.map((model) => {
|
||||
const matchesProviderSearch = !searchText
|
||||
|| model.provider.toLowerCase().includes(searchText.toLowerCase())
|
||||
|| Object.values(model.label).some(label => label.toLowerCase().includes(searchText.toLowerCase()))
|
||||
|
||||
const filteredModels = model.models
|
||||
.filter((modelItem) => {
|
||||
if (modelItem.label[language] !== undefined)
|
||||
@ -108,8 +129,11 @@ const Popup: FC<PopupProps> = ({
|
||||
return modelItem.features?.includes(feature) ?? false
|
||||
})
|
||||
})
|
||||
if (!matchesProviderSearch || filteredModels.length === 0)
|
||||
return null
|
||||
|
||||
return { ...model, models: filteredModels }
|
||||
}).filter(model => model.models.length > 0)
|
||||
}).filter((model): model is Model => model !== null)
|
||||
|
||||
if (defaultModel?.provider) {
|
||||
filtered.sort((a, b) => {
|
||||
@ -120,12 +144,12 @@ const Popup: FC<PopupProps> = ({
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [defaultModel?.provider, language, modelList, scopeFeatures, searchText])
|
||||
}, [defaultModel?.provider, installedModelList, language, scopeFeatures, searchText])
|
||||
|
||||
const marketplaceProviders = useMemo(() => {
|
||||
const installedProviders = new Set(modelList.map(m => m.provider))
|
||||
const installedProviders = new Set(modelProviders.map(provider => provider.provider))
|
||||
return MODEL_PROVIDER_QUOTA_GET_PAID.filter(key => !installedProviders.has(key))
|
||||
}, [modelList])
|
||||
}, [modelProviders])
|
||||
|
||||
return (
|
||||
<div className="max-h-[480px] overflow-y-auto no-scrollbar">
|
||||
@ -160,7 +184,7 @@ const Popup: FC<PopupProps> = ({
|
||||
{showCreditsExhaustedAlert && (
|
||||
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
|
||||
)}
|
||||
<div className="p-1">
|
||||
<div className="px-1 pb-1">
|
||||
{
|
||||
filteredModelList.map(model => (
|
||||
<PopupItem
|
||||
@ -172,7 +196,7 @@ const Popup: FC<PopupProps> = ({
|
||||
/>
|
||||
))
|
||||
}
|
||||
{!filteredModelList.length && !modelList.length && (
|
||||
{!filteredModelList.length && !installedModelList.length && (
|
||||
<div className="flex flex-col gap-2 rounded-[10px] bg-gradient-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
|
||||
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
|
||||
@ -198,7 +222,7 @@ const Popup: FC<PopupProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!filteredModelList.length && modelList.length > 0 && (
|
||||
{!filteredModelList.length && installedModelList.length > 0 && (
|
||||
<div className="break-all px-3 py-1.5 text-center text-xs leading-[18px] text-text-tertiary">
|
||||
{`No model found for \u201C${searchText}\u201D`}
|
||||
</div>
|
||||
@ -237,7 +261,7 @@ const Popup: FC<PopupProps> = ({
|
||||
'shrink-0 backdrop-blur-[5px]',
|
||||
!isInstalling && 'hidden group-hover:flex',
|
||||
)}
|
||||
disabled={isInstalling}
|
||||
disabled={isInstalling || isMarketplacePluginsLoading}
|
||||
onClick={() => handleInstallPlugin(key)}
|
||||
>
|
||||
{isInstalling && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin" />}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { ModelStatusEnum } from './declarations'
|
||||
|
||||
export const MODEL_STATUS_I18N_KEY: Partial<Record<ModelStatusEnum, string>> = {
|
||||
[ModelStatusEnum.quotaExceeded]: 'modelProvider.selector.creditsExhausted',
|
||||
[ModelStatusEnum.noConfigure]: 'modelProvider.selector.configureRequired',
|
||||
[ModelStatusEnum.noPermission]: 'modelProvider.selector.incompatible',
|
||||
[ModelStatusEnum.disabled]: 'modelProvider.selector.disabled',
|
||||
[ModelStatusEnum.credentialRemoved]: 'modelProvider.selector.apiKeyUnavailable',
|
||||
}
|
||||
@ -234,6 +234,24 @@ describe('getMarketplacePluginsByCollectionId', () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should send an empty body when query is omitted', async () => {
|
||||
mockCollectionPlugins.mockResolvedValueOnce({
|
||||
data: { plugins: [] },
|
||||
})
|
||||
|
||||
const { getMarketplacePluginsByCollectionId } = await import('../utils')
|
||||
await getMarketplacePluginsByCollectionId('test-collection')
|
||||
|
||||
expect(mockCollectionPlugins).toHaveBeenCalledWith({
|
||||
params: {
|
||||
collectionId: 'test-collection',
|
||||
},
|
||||
body: {},
|
||||
}, expect.objectContaining({
|
||||
signal: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should pass abort signal when provided', async () => {
|
||||
const mockPlugins = [{ type: 'plugin', org: 'test', name: 'plugin1' }]
|
||||
mockCollectionPlugins.mockResolvedValueOnce({
|
||||
|
||||
@ -63,7 +63,7 @@ export const getMarketplacePluginsByCollectionId = async (
|
||||
params: {
|
||||
collectionId,
|
||||
},
|
||||
body: query,
|
||||
body: query ?? {},
|
||||
}, {
|
||||
signal: options?.signal,
|
||||
})
|
||||
|
||||
@ -114,15 +114,8 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
}
|
||||
}, [scopedModelList, value?.provider, value?.model])
|
||||
|
||||
const hasDeprecated = useMemo(() => {
|
||||
return !currentProvider || !currentModel
|
||||
}, [currentModel, currentProvider])
|
||||
const modelDisabled = useMemo(() => {
|
||||
return currentModel?.status !== ModelStatusEnum.active
|
||||
}, [currentModel?.status])
|
||||
const disabled = useMemo(() => {
|
||||
return !isAPIKeySet || hasDeprecated || modelDisabled
|
||||
}, [hasDeprecated, isAPIKeySet, modelDisabled])
|
||||
const hasDeprecated = !currentProvider || !currentModel
|
||||
const disabled = !isAPIKeySet || hasDeprecated || currentModel?.status !== ModelStatusEnum.active
|
||||
|
||||
const handleChangeModel = async ({ provider, model }: DefaultModel) => {
|
||||
const targetProvider = scopedModelList.find(modelItem => modelItem.provider === provider)
|
||||
@ -203,9 +196,6 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
renderTrigger
|
||||
? renderTrigger({
|
||||
open,
|
||||
disabled,
|
||||
modelDisabled,
|
||||
hasDeprecated,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
providerName: value?.provider,
|
||||
@ -225,10 +215,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
)
|
||||
: (
|
||||
<Trigger
|
||||
disabled={disabled}
|
||||
isInWorkflow={isInWorkflow}
|
||||
modelDisabled={modelDisabled}
|
||||
hasDeprecated={hasDeprecated}
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName={value?.provider}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TaskStatus } from '@/app/components/plugins/types'
|
||||
import { PluginSource, TaskStatus } from '@/app/components/plugins/types'
|
||||
// Import mocked modules
|
||||
import { useMutationClearTaskPlugin, usePluginTaskList } from '@/service/use-plugins'
|
||||
import PluginTaskList from '../components/plugin-task-list'
|
||||
@ -30,6 +30,7 @@ vi.mock('@/context/i18n', () => ({
|
||||
const createMockPlugin = (overrides: Partial<PluginStatus> = {}): PluginStatus => ({
|
||||
plugin_unique_identifier: `plugin-${Math.random().toString(36).substr(2, 9)}`,
|
||||
plugin_id: 'test-plugin',
|
||||
source: PluginSource.marketplace,
|
||||
status: TaskStatus.running,
|
||||
message: '',
|
||||
icon: 'test-icon.png',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { PluginInfoFromMarketPlace, PluginStatus } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum, PluginSource, TaskStatus } from '@/app/components/plugins/types'
|
||||
import { fetchPluginInfoFromMarketPlace } from '@/service/plugins'
|
||||
|
||||
import ErrorPluginItem from '../error-plugin-item'
|
||||
@ -43,6 +43,7 @@ function createMarketplaceResponse(identifier: string, version: string) {
|
||||
const createPlugin = (overrides: Partial<PluginStatus> = {}): PluginStatus => ({
|
||||
plugin_unique_identifier: 'org/plugin:1.0.0',
|
||||
plugin_id: 'org/plugin',
|
||||
source: PluginSource.marketplace,
|
||||
status: TaskStatus.failed,
|
||||
message: '',
|
||||
icon: 'icon.png',
|
||||
@ -102,7 +103,7 @@ describe('ErrorPluginItem', () => {
|
||||
it('should show marketplace error message for marketplace plugins', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
|
||||
plugin={createPlugin({ source: PluginSource.marketplace, plugin_id: 'org/my-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
@ -115,7 +116,7 @@ describe('ErrorPluginItem', () => {
|
||||
it('should show github error message for github plugins', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'https://github.com/user/repo' })}
|
||||
plugin={createPlugin({ source: PluginSource.github, plugin_id: 'https://github.com/user/repo' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
@ -128,7 +129,7 @@ describe('ErrorPluginItem', () => {
|
||||
it('should show unknown error message for unknown source plugins', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'local-only-plugin' })}
|
||||
plugin={createPlugin({ source: PluginSource.local, plugin_id: 'local-only-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
@ -156,7 +157,7 @@ describe('ErrorPluginItem', () => {
|
||||
it('should show "Install from Marketplace" button for marketplace plugins', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
|
||||
plugin={createPlugin({ source: PluginSource.marketplace, plugin_id: 'org/my-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
@ -169,7 +170,7 @@ describe('ErrorPluginItem', () => {
|
||||
it('should show "Install from GitHub" button for github plugins', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'https://github.com/user/repo' })}
|
||||
plugin={createPlugin({ source: PluginSource.github, plugin_id: 'https://github.com/user/repo' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
@ -182,7 +183,7 @@ describe('ErrorPluginItem', () => {
|
||||
it('should not show action button for unknown source plugins', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'local-only-plugin' })}
|
||||
plugin={createPlugin({ source: PluginSource.local, plugin_id: 'local-only-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
@ -191,6 +192,20 @@ describe('ErrorPluginItem', () => {
|
||||
|
||||
expect(screen.queryByText(/plugin\.task\.installFrom/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use source instead of plugin_id heuristics when deciding button text', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ source: PluginSource.github, plugin_id: 'org/my-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/plugin\.task\.installFromMarketplace/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
@ -218,7 +233,7 @@ describe('ErrorPluginItem', () => {
|
||||
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
|
||||
plugin={createPlugin({ source: PluginSource.marketplace, plugin_id: 'org/my-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
@ -240,7 +255,7 @@ describe('ErrorPluginItem', () => {
|
||||
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
|
||||
plugin={createPlugin({ source: PluginSource.marketplace, plugin_id: 'org/my-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
@ -263,7 +278,7 @@ describe('ErrorPluginItem', () => {
|
||||
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
|
||||
plugin={createPlugin({ source: PluginSource.marketplace, plugin_id: 'org/my-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
@ -282,7 +297,7 @@ describe('ErrorPluginItem', () => {
|
||||
it('should not fetch when plugin_id has fewer than 2 parts', async () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'single-part' })}
|
||||
plugin={createPlugin({ source: PluginSource.local, plugin_id: 'single-part' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
@ -296,10 +311,10 @@ describe('ErrorPluginItem', () => {
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should detect github source with github in URL', () => {
|
||||
it('should render github action when source is github even if plugin_id looks like a URL', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'http://github.com/user/repo' })}
|
||||
plugin={createPlugin({ source: PluginSource.github, plugin_id: 'http://github.com/user/repo' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
@ -309,15 +324,16 @@ describe('ErrorPluginItem', () => {
|
||||
expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close install modal when onSuccess is called', async () => {
|
||||
it('should close install modal and clear the error item when onSuccess is called', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMarketplaceResponse('org/p:1.0.0', '1.0.0'))
|
||||
const onClear = vi.fn()
|
||||
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'org/p' })}
|
||||
plugin={createPlugin({ source: PluginSource.marketplace, plugin_id: 'org/p' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
onClear={onClear}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -330,19 +346,35 @@ describe('ErrorPluginItem', () => {
|
||||
fireEvent.click(screen.getByText('Success'))
|
||||
|
||||
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should detect github source when id contains github keyword', () => {
|
||||
it('should show unknown action state for local source even if id contains github keyword', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'my-github-plugin' })}
|
||||
plugin={createPlugin({ source: PluginSource.local, plugin_id: 'my-github-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/plugin\.task\.installFromGithub/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/plugin\.task\.errorMsg\.unknown/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show unknown error message for debugging source plugins', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ source: PluginSource.debugging, plugin_id: 'remote-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/plugin\.task\.errorMsg\.unknown/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/plugin\.task\.installFrom/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TaskStatus } from '@/app/components/plugins/types'
|
||||
import { PluginSource, TaskStatus } from '@/app/components/plugins/types'
|
||||
import PluginItem from '../plugin-item'
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
@ -14,6 +14,7 @@ const mockGetIconUrl = vi.fn((icon: string) => `https://example.com/icons/${icon
|
||||
const createPlugin = (overrides: Partial<PluginStatus> = {}): PluginStatus => ({
|
||||
plugin_unique_identifier: 'org/plugin:1.0.0',
|
||||
plugin_id: 'org/plugin',
|
||||
source: PluginSource.marketplace,
|
||||
status: TaskStatus.running,
|
||||
message: '',
|
||||
icon: 'icon.png',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TaskStatus } from '@/app/components/plugins/types'
|
||||
import { PluginSource, TaskStatus } from '@/app/components/plugins/types'
|
||||
import PluginSection from '../plugin-section'
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
@ -14,6 +14,7 @@ const mockGetIconUrl = vi.fn((icon: string) => `https://icons/${icon}`)
|
||||
const createPlugin = (id: string, name: string, message = ''): PluginStatus => ({
|
||||
plugin_unique_identifier: id,
|
||||
plugin_id: `org/${name.toLowerCase()}`,
|
||||
source: PluginSource.marketplace,
|
||||
status: TaskStatus.running,
|
||||
message,
|
||||
icon: `${name.toLowerCase()}.png`,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TaskStatus } from '@/app/components/plugins/types'
|
||||
import { PluginSource, TaskStatus } from '@/app/components/plugins/types'
|
||||
import PluginTaskList from '../plugin-task-list'
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
@ -26,6 +26,7 @@ const mockGetIconUrl = vi.fn((icon: string) => `https://icons/${icon}`)
|
||||
const createPlugin = (id: string, name: string, overrides: Partial<PluginStatus> = {}): PluginStatus => ({
|
||||
plugin_unique_identifier: id,
|
||||
plugin_id: `org/${name.toLowerCase()}`,
|
||||
source: PluginSource.marketplace,
|
||||
status: TaskStatus.running,
|
||||
message: '',
|
||||
icon: `${name.toLowerCase()}.png`,
|
||||
|
||||
@ -5,19 +5,10 @@ import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import { PluginSource } from '@/app/components/plugins/types'
|
||||
import { fetchPluginInfoFromMarketPlace } from '@/service/plugins'
|
||||
import PluginItem from './plugin-item'
|
||||
|
||||
type PluginSource = 'marketplace' | 'github' | 'unknown'
|
||||
|
||||
function getPluginSource(pluginId: string): PluginSource {
|
||||
if (pluginId.includes('/') && !pluginId.startsWith('http'))
|
||||
return 'marketplace'
|
||||
if (pluginId.startsWith('http') || pluginId.includes('github'))
|
||||
return 'github'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
type ErrorPluginItemProps = {
|
||||
plugin: PluginStatus
|
||||
getIconUrl: (icon: string) => string
|
||||
@ -27,7 +18,7 @@ type ErrorPluginItemProps = {
|
||||
|
||||
const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, language, onClear }) => {
|
||||
const { t } = useTranslation()
|
||||
const source = getPluginSource(plugin.plugin_id)
|
||||
const source = plugin.source
|
||||
const [showInstallModal, setShowInstallModal] = useState(false)
|
||||
const [installPayload, setInstallPayload] = useState<{ uniqueIdentifier: string, manifest: Plugin } | null>(null)
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
@ -75,16 +66,16 @@ const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, languag
|
||||
}
|
||||
}, [plugin.plugin_id, plugin.labels, plugin.icon])
|
||||
|
||||
const errorMsgKey = {
|
||||
marketplace: 'task.errorMsg.marketplace',
|
||||
github: 'task.errorMsg.github',
|
||||
unknown: 'task.errorMsg.unknown',
|
||||
}[source] as 'task.errorMsg.marketplace'
|
||||
const errorMsgKey: 'task.errorMsg.marketplace' | 'task.errorMsg.github' | 'task.errorMsg.unknown' = source === PluginSource.marketplace
|
||||
? 'task.errorMsg.marketplace'
|
||||
: source === PluginSource.github
|
||||
? 'task.errorMsg.github'
|
||||
: 'task.errorMsg.unknown'
|
||||
|
||||
const errorMsg = t(errorMsgKey, { ns: 'plugin' })
|
||||
|
||||
const renderAction = () => {
|
||||
if (source === 'marketplace') {
|
||||
if (source === PluginSource.marketplace) {
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<Button variant="secondary" size="small" loading={isFetching} onClick={handleInstallFromMarketplace}>
|
||||
@ -93,7 +84,7 @@ const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, languag
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (source === 'github') {
|
||||
if (source === PluginSource.github) {
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<Button variant="secondary" size="small">
|
||||
@ -130,7 +121,10 @@ const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, languag
|
||||
uniqueIdentifier={installPayload.uniqueIdentifier}
|
||||
manifest={installPayload.manifest}
|
||||
onClose={() => setShowInstallModal(false)}
|
||||
onSuccess={() => setShowInstallModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowInstallModal(false)
|
||||
onClear()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -432,6 +432,7 @@ export enum TaskStatus {
|
||||
export type PluginStatus = {
|
||||
plugin_unique_identifier: string
|
||||
plugin_id: string
|
||||
source: PluginSource
|
||||
status: TaskStatus
|
||||
message: string
|
||||
icon: string
|
||||
|
||||
@ -27,6 +27,7 @@ import {
|
||||
VariableX,
|
||||
WebhookLine,
|
||||
} from '@/app/components/base/icons/src/vender/workflow'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { BlockEnum } from './types'
|
||||
|
||||
@ -82,6 +83,17 @@ const getIcon = (type: BlockEnum, className: string) => {
|
||||
|
||||
return <DefaultIcon className={className} />
|
||||
}
|
||||
|
||||
const normalizeToolIconUrl = (toolIcon: string) => {
|
||||
const protectedPluginIconPath = '/workspaces/current/plugin/icon'
|
||||
const pathIndex = toolIcon.indexOf(protectedPluginIconPath)
|
||||
|
||||
if (pathIndex < 0)
|
||||
return toolIcon
|
||||
|
||||
return `${API_PREFIX}${toolIcon.slice(pathIndex)}`
|
||||
}
|
||||
|
||||
const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
|
||||
[BlockEnum.Start]: 'bg-util-colors-blue-brand-blue-brand-500',
|
||||
[BlockEnum.LLM]: 'bg-util-colors-indigo-indigo-500',
|
||||
@ -119,6 +131,9 @@ const BlockIcon: FC<BlockIconProps> = ({
|
||||
}) => {
|
||||
const isToolOrDataSourceOrTriggerPlugin = type === BlockEnum.Tool || type === BlockEnum.DataSource || type === BlockEnum.TriggerPlugin
|
||||
const showDefaultIcon = !isToolOrDataSourceOrTriggerPlugin || !toolIcon
|
||||
const resolvedToolIcon = typeof toolIcon === 'string'
|
||||
? normalizeToolIconUrl(toolIcon)
|
||||
: toolIcon
|
||||
|
||||
return (
|
||||
<div className={
|
||||
@ -142,12 +157,12 @@ const BlockIcon: FC<BlockIconProps> = ({
|
||||
!showDefaultIcon && (
|
||||
<>
|
||||
{
|
||||
typeof toolIcon === 'string'
|
||||
typeof resolvedToolIcon === 'string'
|
||||
? (
|
||||
<div
|
||||
className="h-full w-full shrink-0 rounded-md bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: `url(${toolIcon})`,
|
||||
backgroundImage: `url(${resolvedToolIcon})`,
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
@ -156,8 +171,8 @@ const BlockIcon: FC<BlockIconProps> = ({
|
||||
<AppIcon
|
||||
className="!h-full !w-full shrink-0"
|
||||
size="tiny"
|
||||
icon={toolIcon?.content}
|
||||
background={toolIcon?.background}
|
||||
icon={resolvedToolIcon?.content}
|
||||
background={resolvedToolIcon?.background}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4601,19 +4601,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -4625,11 +4612,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@ -406,6 +406,7 @@
|
||||
"modelProvider.model": "Model",
|
||||
"modelProvider.modelAndParameters": "Model and Parameters",
|
||||
"modelProvider.modelHasBeenDeprecated": "This model has been deprecated",
|
||||
"modelProvider.modelSettings": "Model Settings",
|
||||
"modelProvider.models": "Models",
|
||||
"modelProvider.modelsNum": "{{num}} Models",
|
||||
"modelProvider.noModelFound": "No model found for {{model}}",
|
||||
@ -425,9 +426,11 @@
|
||||
"modelProvider.selectModel": "Select your model",
|
||||
"modelProvider.selector.aiCredits": "AI credits",
|
||||
"modelProvider.selector.apiKeyUnavailable": "API Key unavailable",
|
||||
"modelProvider.selector.apiKeyUnavailableTip": "The API key has been removed. Please configure a new API key.",
|
||||
"modelProvider.selector.configure": "Configure",
|
||||
"modelProvider.selector.configureRequired": "Configure required",
|
||||
"modelProvider.selector.creditsExhausted": "Credits exhausted",
|
||||
"modelProvider.selector.creditsExhaustedTip": "Your AI credits have been exhausted. Please upgrade your plan or add an API key.",
|
||||
"modelProvider.selector.disabled": "Disabled",
|
||||
"modelProvider.selector.discoverMoreInMarketplace": "Discover more in Marketplace",
|
||||
"modelProvider.selector.emptySetting": "Please go to settings to configure",
|
||||
|
||||
@ -406,6 +406,7 @@
|
||||
"modelProvider.model": "模型",
|
||||
"modelProvider.modelAndParameters": "模型及参数",
|
||||
"modelProvider.modelHasBeenDeprecated": "该模型已废弃",
|
||||
"modelProvider.modelSettings": "模型设置",
|
||||
"modelProvider.models": "模型列表",
|
||||
"modelProvider.modelsNum": "{{num}} 个模型",
|
||||
"modelProvider.noModelFound": "找不到模型 {{model}}",
|
||||
@ -425,9 +426,11 @@
|
||||
"modelProvider.selectModel": "选择您的模型",
|
||||
"modelProvider.selector.aiCredits": "AI 积分",
|
||||
"modelProvider.selector.apiKeyUnavailable": "API Key 不可用",
|
||||
"modelProvider.selector.apiKeyUnavailableTip": "API Key 已被移除,请重新配置 API Key。",
|
||||
"modelProvider.selector.configure": "配置",
|
||||
"modelProvider.selector.configureRequired": "需要配置",
|
||||
"modelProvider.selector.creditsExhausted": "额度已用尽",
|
||||
"modelProvider.selector.creditsExhaustedTip": "AI 积分已用尽,请升级计划或添加 API Key。",
|
||||
"modelProvider.selector.disabled": "已禁用",
|
||||
"modelProvider.selector.discoverMoreInMarketplace": "在插件市场发现更多",
|
||||
"modelProvider.selector.emptySetting": "请前往设置进行配置",
|
||||
|
||||
Reference in New Issue
Block a user