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:
yyh
2026-03-11 17:27:10 +08:00
30 changed files with 750 additions and 453 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -63,7 +63,7 @@ export const getMarketplacePluginsByCollectionId = async (
params: {
collectionId,
},
body: query,
body: query ?? {},
}, {
signal: options?.signal,
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "请前往设置进行配置",