refactor(model-selector): migrate overlays to Popover/Tooltip and unify trigger component

- Migrate PortalToFollowElem to base-ui Popover in model-selector,
  model-parameter-modal, and plugin-detail-panel model-selector
- Migrate legacy Tooltip to compound Tooltip in popup-item and trigger
- Unify EmptyTrigger, ModelTrigger, DeprecatedModelTrigger into a
  single declarative ModelSelectorTrigger that derives state from props
- Remove showDeprecatedWarnIcon boolean prop anti-pattern; deprecated
  state always renders warn icon as part of component's visual contract
- Remove deprecatedClassName prop; component manages disabled styling
- Replace manual triggerRef width measurement with CSS var(--anchor-width)
- Remove tooltip scroll listener (base-ui auto-tracks anchor position)
- Restore conditional placement for workflow mode in plugin-detail-panel
- Prune stale ESLint suppressions for removed deprecated imports
This commit is contained in:
yyh
2026-03-09 23:34:42 +08:00
parent ce0197b107
commit b364b06e51
16 changed files with 672 additions and 770 deletions

View File

@ -3,8 +3,10 @@ import type { FormValue } from '@/app/components/header/account-setting/model-pr
import type { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import type { GenRes } from '@/service/debug'
import type { AppModeEnum, CompletionParams, Model, ModelModeType } from '@/types/app'
import { useSessionStorageState } from 'ahooks'
import useBoolean from 'ahooks/lib/useBoolean'
import {
useBoolean,
useSessionStorageState,
} from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -224,7 +226,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
</div>
<div>
<div className="text-[0px]">
<div className="system-sm-semibold-uppercase mb-1.5 text-text-secondary">{t('codegen.instruction', { ns: 'appDebug' })}</div>
<div className="mb-1.5 text-text-secondary system-sm-semibold-uppercase">{t('codegen.instruction', { ns: 'appDebug' })}</div>
<InstructionEditor
editorKey={editorKey}
value={instruction}
@ -248,7 +250,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
disabled={isLoading}
>
<Generator className="h-4 w-4" />
<span className="text-xs font-semibold ">{t('codegen.generate', { ns: 'appDebug' })}</span>
<span className="text-xs font-semibold">{t('codegen.generate', { ns: 'appDebug' })}</span>
</Button>
</div>
</div>

View File

@ -63,7 +63,7 @@ const SummaryIndexSetting = ({
return (
<div>
<div className="flex h-6 items-center justify-between">
<div className="system-sm-semibold-uppercase flex items-center text-text-secondary">
<div className="flex items-center text-text-secondary system-sm-semibold-uppercase">
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
<Tooltip
triggerClassName="ml-1 h-4 w-4 shrink-0"
@ -80,7 +80,7 @@ const SummaryIndexSetting = ({
{
summaryIndexSetting?.enable && (
<div>
<div className="system-xs-medium-uppercase mb-1.5 mt-2 flex h-6 items-center text-text-tertiary">
<div className="mb-1.5 mt-2 flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
{t('form.summaryModel', { ns: 'datasetSettings' })}
</div>
<ModelSelector
@ -90,7 +90,7 @@ const SummaryIndexSetting = ({
readonly={readonly}
showDeprecatedWarnIcon
/>
<div className="system-xs-medium-uppercase mt-3 flex h-6 items-center text-text-tertiary">
<div className="mt-3 flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
<Textarea
@ -111,12 +111,12 @@ const SummaryIndexSetting = ({
<div className="space-y-4">
<div className="flex gap-x-1">
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
<div className="system-sm-semibold text-text-secondary">
<div className="text-text-secondary system-sm-semibold">
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
</div>
</div>
<div className="py-1.5">
<div className="system-sm-semibold flex items-center text-text-secondary">
<div className="flex items-center text-text-secondary system-sm-semibold">
<Switch
className="mr-2"
value={summaryIndexSetting?.enable ?? false}
@ -127,7 +127,7 @@ const SummaryIndexSetting = ({
summaryIndexSetting?.enable ? t('list.status.enabled', { ns: 'datasetDocuments' }) : t('list.status.disabled', { ns: 'datasetDocuments' })
}
</div>
<div className="system-sm-regular mt-2 text-text-tertiary">
<div className="mt-2 text-text-tertiary system-sm-regular">
{
summaryIndexSetting?.enable && t('form.summaryAutoGenTip', { ns: 'datasetSettings' })
}
@ -142,7 +142,7 @@ const SummaryIndexSetting = ({
<>
<div className="flex gap-x-1">
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
<div className="system-sm-medium text-text-tertiary">
<div className="text-text-tertiary system-sm-medium">
{t('form.summaryModel', { ns: 'datasetSettings' })}
</div>
</div>
@ -159,7 +159,7 @@ const SummaryIndexSetting = ({
</div>
<div className="flex">
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
<div className="system-sm-medium text-text-tertiary">
<div className="text-text-tertiary system-sm-medium">
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
</div>
@ -188,7 +188,7 @@ const SummaryIndexSetting = ({
onChange={handleSummaryIndexEnableChange}
size="md"
/>
<div className="system-sm-semibold text-text-secondary">
<div className="text-text-secondary system-sm-semibold">
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
</div>
</div>
@ -196,7 +196,7 @@ const SummaryIndexSetting = ({
summaryIndexSetting?.enable && (
<>
<div>
<div className="system-sm-medium mb-1.5 flex h-6 items-center text-text-secondary">
<div className="mb-1.5 flex h-6 items-center text-text-secondary system-sm-medium">
{t('form.summaryModel', { ns: 'datasetSettings' })}
</div>
<ModelSelector
@ -209,7 +209,7 @@ const SummaryIndexSetting = ({
/>
</div>
<div>
<div className="system-sm-medium mb-1.5 flex h-6 items-center text-text-secondary">
<div className="mb-1.5 flex h-6 items-center text-text-secondary system-sm-medium">
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
<Textarea

View File

@ -14,10 +14,10 @@ import { useTranslation } from 'react-i18next'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
import Loading from '@/app/components/base/loading'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
Popover,
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'
@ -129,118 +129,118 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
}
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement={isInWorkflow ? 'left' : 'bottom-end'}
offset={4}
onOpenChange={(newOpen) => {
if (readonly)
return
setOpen(newOpen)
}}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => {
if (readonly)
return
setOpen(v => !v)
}}
className="block"
>
{
renderTrigger
? renderTrigger({
open,
disabled,
modelDisabled,
hasDeprecated,
currentProvider,
currentModel,
providerName: provider,
modelId,
})
: (
<Trigger
disabled={disabled}
isInWorkflow={isInWorkflow}
modelDisabled={modelDisabled}
hasDeprecated={hasDeprecated}
currentProvider={currentProvider}
currentModel={currentModel}
providerName={provider}
modelId={modelId}
/>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={cn('z-[60]', portalToFollowElemContentClassName)}>
<div className={cn(popupClassName, 'w-[389px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg')}>
<div className={cn('max-h-[420px] overflow-y-auto p-4 pt-3')}>
<div className="relative">
<div className={cn('mb-1 flex h-6 items-center text-text-secondary system-sm-semibold')}>
{t('modelProvider.model', { ns: 'common' }).toLocaleUpperCase()}
</div>
<ModelSelector
defaultModel={(provider || modelId) ? { provider, model: modelId } : undefined}
modelList={activeTextGenerationModelList}
onSelect={handleChangeModel}
onHide={() => setOpen(false)}
/>
</div>
{
!!parameterRules.length && (
<div className="my-3 h-px bg-divider-subtle" />
)
}
{
isLoading && (
<div className="mt-5"><Loading /></div>
)
}
{
!isLoading && !!parameterRules.length && (
<div className="mb-2 flex items-center justify-between">
<div className={cn('flex h-6 items-center text-text-secondary system-sm-semibold')}>{t('modelProvider.parameters', { ns: 'common' })}</div>
{
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
<PresetsParameter onSelect={handleSelectPresetParameter} />
)
}
</div>
)
}
{
!isLoading && !!parameterRules.length && (
[
...parameterRules,
...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []),
].map(parameter => (
<ParameterItem
key={`${modelId}-${parameter.name}`}
parameterRule={parameter}
value={completionParams?.[parameter.name]}
onChange={v => handleParamChange(parameter.name, v)}
onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)}
<PopoverTrigger
render={(
<div className="block">
{
renderTrigger
? renderTrigger({
open,
disabled,
modelDisabled,
hasDeprecated,
currentProvider,
currentModel,
providerName: provider,
modelId,
})
: (
<Trigger
disabled={disabled}
isInWorkflow={isInWorkflow}
modelDisabled={modelDisabled}
hasDeprecated={hasDeprecated}
currentProvider={currentProvider}
currentModel={currentModel}
providerName={provider}
modelId={modelId}
/>
))
)
}
</div>
{!hideDebugWithMultipleModel && (
<div
className="bg-components-section-burn flex h-[50px] cursor-pointer items-center justify-between rounded-b-xl border-t border-t-divider-subtle px-4 text-text-accent system-sm-regular"
onClick={() => onDebugWithMultipleModelChange?.()}
>
{
debugWithMultipleModel
? t('debugAsSingleModel', { ns: 'appDebug' })
: t('debugAsMultipleModel', { ns: 'appDebug' })
}
<ArrowNarrowLeft className="h-3 w-3 rotate-180" />
</div>
)}
)
}
</div>
</PortalToFollowElemContent>
</div>
</PortalToFollowElem>
)}
/>
<PopoverContent
placement={isInWorkflow ? 'left' : 'bottom-end'}
sideOffset={4}
className={portalToFollowElemContentClassName}
popupClassName={cn(popupClassName, 'w-[389px] rounded-2xl')}
>
<div className="max-h-[420px] overflow-y-auto p-4 pt-3">
<div className="relative">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">
{t('modelProvider.model', { ns: 'common' }).toLocaleUpperCase()}
</div>
<ModelSelector
defaultModel={(provider || modelId) ? { provider, model: modelId } : undefined}
modelList={activeTextGenerationModelList}
onSelect={handleChangeModel}
onHide={() => setOpen(false)}
/>
</div>
{
!!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>
{
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
<PresetsParameter onSelect={handleSelectPresetParameter} />
)
}
</div>
)
}
{
!isLoading && !!parameterRules.length && (
[
...parameterRules,
...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []),
].map(parameter => (
<ParameterItem
key={`${modelId}-${parameter.name}`}
parameterRule={parameter}
value={completionParams?.[parameter.name]}
onChange={v => handleParamChange(parameter.name, v)}
onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)}
isInWorkflow={isInWorkflow}
/>
))
)
}
</div>
{!hideDebugWithMultipleModel && (
<div
className="flex h-[50px] cursor-pointer items-center justify-between rounded-b-xl border-t border-t-divider-subtle px-4 text-text-accent system-sm-regular"
onClick={() => onDebugWithMultipleModelChange?.()}
>
{
debugWithMultipleModel
? t('debugAsSingleModel', { ns: 'appDebug' })
: t('debugAsMultipleModel', { ns: 'appDebug' })
}
<ArrowNarrowLeft className="h-3 w-3 rotate-180" />
</div>
)}
</PopoverContent>
</Popover>
)
}

View File

@ -1,61 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import DeprecatedModelTrigger from './deprecated-model-trigger'
vi.mock('../model-icon', () => ({
default: ({ modelName }: { modelName: string }) => <span>{modelName}</span>,
}))
const mockUseProviderContext = vi.hoisted(() => vi.fn())
vi.mock('@/context/provider-context', () => ({
useProviderContext: mockUseProviderContext,
}))
describe('DeprecatedModelTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseProviderContext.mockReturnValue({
modelProviders: [{ provider: 'someone-else' }, { provider: 'openai' }],
})
})
it('should render model name', () => {
render(<DeprecatedModelTrigger modelName="gpt-deprecated" providerName="openai" />)
expect(screen.getAllByText('gpt-deprecated').length).toBeGreaterThan(0)
})
it('should show deprecated tooltip when warn icon is hovered', async () => {
const { container } = render(
<DeprecatedModelTrigger
modelName="gpt-deprecated"
providerName="openai"
showWarnIcon
/>,
)
const tooltipTrigger = container.querySelector('[data-state]') as HTMLElement
fireEvent.mouseEnter(tooltipTrigger)
expect(await screen.findByText('common.modelProvider.deprecated')).toBeInTheDocument()
})
it('should render when provider is not found', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [{ provider: 'someone-else' }],
})
render(<DeprecatedModelTrigger modelName="gpt-deprecated" providerName="openai" />)
expect(screen.getAllByText('gpt-deprecated').length).toBeGreaterThan(0)
})
it('should not show deprecated tooltip when warn icon is disabled', async () => {
render(
<DeprecatedModelTrigger
modelName="gpt-deprecated"
providerName="openai"
showWarnIcon={false}
/>,
)
expect(screen.queryByText('common.modelProvider.deprecated')).not.toBeInTheDocument()
})
})

View File

@ -1,54 +0,0 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
import ModelIcon from '../model-icon'
type ModelTriggerProps = {
modelName: string
providerName: string
className?: string
showWarnIcon?: boolean
contentClassName?: string
}
const ModelTrigger: FC<ModelTriggerProps> = ({
modelName,
providerName,
className,
showWarnIcon,
contentClassName,
}) => {
const { t } = useTranslation()
const { modelProviders } = useProviderContext()
const currentProvider = modelProviders.find(provider => provider.provider === providerName)
return (
<div
className={cn('group box-content flex h-8 grow cursor-pointer items-center gap-1 rounded-lg bg-components-input-bg-disabled p-[3px] pl-1', className)}
>
<div className={cn('flex w-full items-center', contentClassName)}>
<div className="flex min-w-0 flex-1 items-center gap-1 py-[1px]">
<ModelIcon
className="h-4 w-4"
provider={currentProvider}
modelName={modelName}
/>
<div className="system-sm-regular truncate text-components-input-text-filled">
{modelName}
</div>
</div>
<div className="flex shrink-0 items-center justify-center">
{showWarnIcon && (
<Tooltip popupContent={t('modelProvider.deprecated', { ns: 'common' })}>
<AlertTriangle className="h-4 w-4 text-text-warning-secondary" />
</Tooltip>
)}
</div>
</div>
</div>
)
}
export default ModelTrigger

View File

@ -1,31 +0,0 @@
import { render, screen } from '@testing-library/react'
import EmptyTrigger from './empty-trigger'
describe('EmptyTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render configure model text', () => {
render(<EmptyTrigger open={false} />)
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
})
// open=true: hover bg class present
it('should apply hover background class when open is true', () => {
// Act
const { container } = render(<EmptyTrigger open={true} />)
// Assert
expect(container.firstChild).toHaveClass('bg-components-input-bg-hover')
})
// className prop truthy: custom className appears on root
it('should apply custom className when provided', () => {
// Act
const { container } = render(<EmptyTrigger open={false} className="custom-class" />)
// Assert
expect(container.firstChild).toHaveClass('custom-class')
})
})

View File

@ -1,42 +0,0 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
type ModelTriggerProps = {
open: boolean
className?: string
}
const ModelTrigger: FC<ModelTriggerProps> = ({
open,
className,
}) => {
const { t } = useTranslation()
return (
<div
className={cn(
'group flex h-8 cursor-pointer items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 hover:bg-components-input-bg-hover',
open && 'bg-components-input-bg-hover',
className,
)}
>
<div className="flex h-6 w-6 items-center justify-center">
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span className="i-ri-brain-2-line h-3.5 w-3.5 text-text-quaternary" />
</div>
</div>
<div className="flex grow items-center gap-1 truncate px-1 py-[3px]">
<div
className="grow truncate text-[13px] text-text-quaternary"
title="Configure model"
>
{t('detailPanel.configureModel', { ns: 'plugin' })}
</div>
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary" />
</div>
</div>
</div>
)
}
export default ModelTrigger

View File

@ -5,17 +5,15 @@ import type {
ModelFeatureEnum,
ModelItem,
} from '../declarations'
import { useRef, useState } from 'react'
import { useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { cn } from '@/utils/classnames'
import { useCurrentProviderAndModel } from '../hooks'
import DeprecatedModelTrigger from './deprecated-model-trigger'
import EmptyTrigger from './empty-trigger'
import ModelTrigger from './model-trigger'
import ModelSelectorTrigger from './model-selector-trigger'
import Popup from './popup'
type ModelSelectorProps = {
@ -40,10 +38,9 @@ const ModelSelector: FC<ModelSelectorProps> = ({
readonly,
scopeFeatures = [],
deprecatedClassName,
showDeprecatedWarnIcon = false,
showDeprecatedWarnIcon = true,
}) => {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLDivElement>(null)
const {
currentProvider,
currentModel,
@ -59,71 +56,60 @@ const ModelSelector: FC<ModelSelectorProps> = ({
onSelect({ provider, model: model.model })
}
const handleToggle = () => {
if (readonly)
return
setOpen(v => !v)
}
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
onOpenChange={(newOpen) => {
if (readonly)
return
setOpen(newOpen)
}}
>
<div ref={triggerRef} className={cn('relative')}>
<PortalToFollowElemTrigger
onClick={handleToggle}
className="block"
>
{
currentModel && currentProvider && (
<ModelTrigger
open={open}
provider={currentProvider}
model={currentModel}
className={triggerClassName}
readonly={readonly}
/>
)
}
{
!currentModel && defaultModel && (
<DeprecatedModelTrigger
modelName={defaultModel?.model || ''}
providerName={defaultModel?.provider || ''}
className={triggerClassName}
showWarnIcon={showDeprecatedWarnIcon}
contentClassName={deprecatedClassName}
/>
)
}
{
!defaultModel && (
<EmptyTrigger
open={open}
className={triggerClassName}
/>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={`z-[1002] ${popupClassName}`}>
<Popup
defaultModel={defaultModel}
modelList={modelList}
onSelect={handleSelect}
scopeFeatures={scopeFeatures}
onHide={() => {
setOpen(false)
onHide?.()
}}
triggerRef={triggerRef}
/>
</PortalToFollowElemContent>
</div>
</PortalToFollowElem>
<PopoverTrigger
render={(
<button
type="button"
className="block w-full border-0 bg-transparent p-0 text-left"
disabled={readonly}
>
<ModelSelectorTrigger
currentProvider={currentProvider}
currentModel={currentModel}
defaultModel={defaultModel}
open={open}
readonly={readonly}
className={triggerClassName}
deprecatedClassName={deprecatedClassName}
showDeprecatedWarnIcon={showDeprecatedWarnIcon}
/>
</button>
)}
/>
{/*
* TODO(overlay-migration): temporary layering hack.
* Some callers still render ModelSelector inside legacy high-z modals
* (e.g. code/automatic generators at z-[1000]). Keep this selector above
* them until those call sites are fully migrated to unified base/ui overlays.
*/}
<PopoverContent
placement="bottom-start"
sideOffset={4}
className={cn('z-[1002]', popupClassName)}
popupClassName="overflow-hidden rounded-lg"
popupProps={{ style: { minWidth: '320px', width: 'var(--anchor-width, auto)' } }}
>
<Popup
defaultModel={defaultModel}
modelList={modelList}
onSelect={handleSelect}
scopeFeatures={scopeFeatures}
onHide={() => {
setOpen(false)
onHide?.()
}}
/>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,193 @@
import type { Model, ModelItem } from '../declarations'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
ConfigurationMethodEnum,
ModelFeatureEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '../declarations'
import ModelSelectorTrigger from './model-selector-trigger'
const mockUseProviderContext = vi.hoisted(() => vi.fn())
vi.mock('@/context/provider-context', () => ({
useProviderContext: mockUseProviderContext,
}))
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
model_type: ModelTypeEnum.textGeneration,
features: [ModelFeatureEnum.vision],
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: { mode: 'chat', context_size: 4096 },
load_balancing_enabled: false,
...overrides,
})
const createModel = (overrides: Partial<Model> = {}): Model => ({
provider: 'openai',
icon_small: {
en_US: 'https://example.com/openai-light.png',
zh_Hans: 'https://example.com/openai-light.png',
},
icon_small_dark: {
en_US: 'https://example.com/openai-dark.png',
zh_Hans: 'https://example.com/openai-dark.png',
},
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [createModelItem()],
status: ModelStatusEnum.active,
...overrides,
})
describe('ModelSelectorTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseProviderContext.mockReturnValue({
modelProviders: [createModel()],
})
})
describe('Rendering', () => {
it('should render empty state when no model is selected', () => {
const { container } = render(<ModelSelectorTrigger />)
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
expect(container.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
expect(container.firstElementChild).toHaveClass('bg-components-input-bg-normal')
})
it('should render selected model details when model is active', () => {
const currentProvider = createModel()
const currentModel = createModelItem()
const { container } = render(
<ModelSelectorTrigger
currentProvider={currentProvider}
currentModel={currentModel}
/>,
)
expect(screen.getByText('GPT-4')).toBeInTheDocument()
expect(screen.getByText('CHAT')).toBeInTheDocument()
expect(container.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
expect(container.firstElementChild).toHaveClass('bg-components-input-bg-normal')
})
it('should render deprecated default model and disabled style when selection is missing', () => {
const { container } = render(
<ModelSelectorTrigger
defaultModel={{ provider: 'openai', model: 'legacy-model' }}
/>,
)
expect(screen.getByText('legacy-model')).toBeInTheDocument()
expect(container.firstElementChild).toHaveClass('bg-components-input-bg-disabled')
expect(container.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className to root element', () => {
const { container } = render(<ModelSelectorTrigger className="custom-trigger" />)
expect(container.firstElementChild).toHaveClass('custom-trigger')
})
it('should apply open background style when open is true and model is active', () => {
const { container } = render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem()}
open
/>,
)
expect(container.firstElementChild).toHaveClass('bg-components-input-bg-hover')
})
it('should hide the expand arrow when readonly is true', () => {
const { container } = render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem()}
readonly
/>,
)
expect(container.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
})
})
describe('Status Handling', () => {
it('should show status badge when selected model is not active and not readonly', () => {
render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem({ status: ModelStatusEnum.noConfigure })}
/>,
)
expect(screen.getByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument()
})
it('should not show status badge when selected model is readonly', () => {
render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem({ status: ModelStatusEnum.noConfigure })}
readonly
/>,
)
expect(screen.queryByText('common.modelProvider.selector.configureRequired')).not.toBeInTheDocument()
})
it('should show incompatible tooltip when hovering no-permission status badge', async () => {
const user = userEvent.setup()
render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem({ status: ModelStatusEnum.noPermission })}
/>,
)
await user.hover(screen.getByText('common.modelProvider.selector.incompatible'))
expect(await screen.findByText('common.modelProvider.selector.incompatibleTip')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should show deprecated tooltip when hovering warn icon', async () => {
const user = userEvent.setup()
const { container } = render(
<ModelSelectorTrigger
defaultModel={{ provider: 'openai', model: 'legacy-model' }}
/>,
)
const warnIcon = container.querySelector('.i-ri-alert-line')
expect(warnIcon).toBeInTheDocument()
await user.hover(warnIcon as HTMLElement)
expect(await screen.findByText('common.modelProvider.deprecated')).toBeInTheDocument()
})
it('should render fallback icon when deprecated provider is not found', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [],
})
const { container } = render(
<ModelSelectorTrigger
defaultModel={{ provider: 'unknown-provider', model: 'legacy-model' }}
/>,
)
expect(container.querySelector('img[alt="model-icon"]')).not.toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,146 @@
import type { FC } from 'react'
import type {
DefaultModel,
Model,
ModelItem,
} from '../declarations'
import { useTranslation } from 'react-i18next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
import { ModelStatusEnum } from '../declarations'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
const STATUS_I18N_KEY: Partial<Record<ModelStatusEnum, string>> = {
[ModelStatusEnum.quotaExceeded]: 'modelProvider.selector.creditsExhausted',
[ModelStatusEnum.noConfigure]: 'modelProvider.selector.configureRequired',
[ModelStatusEnum.noPermission]: 'modelProvider.selector.incompatible',
[ModelStatusEnum.disabled]: 'modelProvider.selector.disabled',
[ModelStatusEnum.credentialRemoved]: 'modelProvider.selector.apiKeyUnavailable',
}
type ModelSelectorTriggerProps = {
currentProvider?: Model
currentModel?: ModelItem
defaultModel?: DefaultModel
open?: boolean
readonly?: boolean
className?: string
deprecatedClassName?: string
showDeprecatedWarnIcon?: boolean
}
const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
currentProvider,
currentModel,
defaultModel,
open,
readonly,
className,
deprecatedClassName,
showDeprecatedWarnIcon = true,
}) => {
const { t } = useTranslation()
const { modelProviders } = useProviderContext()
const isSelected = !!currentProvider && !!currentModel
const isDeprecated = !isSelected && !!defaultModel
const isEmpty = !isSelected && !defaultModel
const isActive = isSelected && currentModel.status === ModelStatusEnum.active
const isDisabled = isDeprecated || (isSelected && !isActive)
const statusI18nKey = isSelected ? STATUS_I18N_KEY[currentModel.status] : undefined
const deprecatedProvider = isDeprecated
? modelProviders.find(p => p.provider === defaultModel.provider)
: undefined
return (
<div
className={cn(
'group flex h-8 items-center gap-0.5 rounded-lg p-1',
isDisabled
? 'bg-components-input-bg-disabled'
: 'bg-components-input-bg-normal',
!readonly && !isDisabled && 'cursor-pointer hover:bg-components-input-bg-hover',
open && !isDisabled && 'bg-components-input-bg-hover',
className,
)}
>
{isEmpty
? (
<div className="flex h-6 w-6 items-center justify-center">
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span className="i-ri-brain-2-line h-3.5 w-3.5 text-text-quaternary" />
</div>
</div>
)
: (
<ModelIcon
className="p-0.5"
provider={isSelected ? currentProvider : deprecatedProvider}
modelName={isSelected ? currentModel.model : defaultModel?.model}
/>
)}
<div className={cn('flex grow items-center gap-1 truncate px-1 py-[3px]', isDeprecated && deprecatedClassName)}>
{isSelected && (
<ModelName
className="grow"
modelItem={currentModel}
showMode
showFeatures
/>
)}
{isDeprecated && (
<div className="grow truncate text-components-input-text-filled system-sm-regular">
{defaultModel.model}
</div>
)}
{isEmpty && (
<div className="grow truncate text-[13px] text-text-quaternary">
{t('detailPanel.configureModel', { ns: 'plugin' })}
</div>
)}
{isSelected && !readonly && !isActive && statusI18nKey && (
<Tooltip>
<TooltipTrigger
disabled={currentModel.status !== ModelStatusEnum.noPermission}
render={(
<div className="flex shrink-0 items-center gap-[3px] rounded-md border border-text-warning px-[5px] py-0.5">
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
<span className="whitespace-nowrap text-text-warning system-xs-medium">
{t(statusI18nKey as 'modelProvider.selector.creditsExhausted', { ns: 'common' })}
</span>
</div>
)}
/>
<TooltipContent placement="top">
{t('modelProvider.selector.incompatibleTip', { ns: 'common' })}
</TooltipContent>
</Tooltip>
)}
{isDeprecated && showDeprecatedWarnIcon && (
<Tooltip>
<TooltipTrigger render={(
<span className="i-ri-alert-line h-4 w-4 shrink-0 text-text-warning-secondary" />
)}
/>
<TooltipContent placement="top">
{t('modelProvider.deprecated', { ns: 'common' })}
</TooltipContent>
</Tooltip>
)}
{!readonly && (isActive || isEmpty) && (
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5 shrink-0 text-text-tertiary" />
)}
</div>
</div>
)
}
export default ModelSelectorTrigger

View File

@ -1,88 +0,0 @@
import type { Model, ModelItem } from '../declarations'
import { render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '../declarations'
import ModelTrigger from './model-trigger'
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
...actual,
useLanguage: () => 'en_US',
}
})
vi.mock('../model-icon', () => ({
default: ({ modelName }: { modelName: string }) => <span>{modelName}</span>,
}))
vi.mock('../model-name', () => ({
default: ({ modelItem }: { modelItem: ModelItem }) => <span>{modelItem.label.en_US}</span>,
}))
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
model_type: ModelTypeEnum.textGeneration,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
...overrides,
})
const makeModel = (overrides: Partial<Model> = {}): Model => ({
provider: 'openai',
icon_small: { en_US: '', zh_Hans: '' },
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [makeModelItem()],
status: ModelStatusEnum.active,
...overrides,
})
describe('ModelTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should show model name', () => {
render(
<ModelTrigger
open
provider={makeModel()}
model={makeModelItem()}
/>,
)
expect(screen.getByText('GPT-4')).toBeInTheDocument()
})
it('should show status badge when model is not active', () => {
render(
<ModelTrigger
open={false}
provider={makeModel()}
model={makeModelItem({ status: ModelStatusEnum.noConfigure })}
/>,
)
expect(screen.getByText(/modelProvider\.selector\.configureRequired/)).toBeInTheDocument()
})
it('should not show status icon when readonly', () => {
render(
<ModelTrigger
open={false}
provider={makeModel()}
model={makeModelItem({ status: ModelStatusEnum.noConfigure })}
readonly
/>,
)
expect(screen.getByText('GPT-4')).toBeInTheDocument()
expect(screen.queryByText(/modelProvider\.selector\.configureRequired/)).not.toBeInTheDocument()
})
})

View File

@ -1,89 +0,0 @@
import type { FC } from 'react'
import type {
Model,
ModelItem,
} from '../declarations'
import { useTranslation } from 'react-i18next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { cn } from '@/utils/classnames'
import {
ModelStatusEnum,
} from '../declarations'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
const STATUS_I18N_KEY: Partial<Record<ModelStatusEnum, string>> = {
[ModelStatusEnum.quotaExceeded]: 'modelProvider.selector.creditsExhausted',
[ModelStatusEnum.noConfigure]: 'modelProvider.selector.configureRequired',
[ModelStatusEnum.noPermission]: 'modelProvider.selector.incompatible',
[ModelStatusEnum.disabled]: 'modelProvider.selector.disabled',
[ModelStatusEnum.credentialRemoved]: 'modelProvider.selector.apiKeyUnavailable',
}
type ModelTriggerProps = {
open: boolean
provider: Model
model: ModelItem
className?: string
readonly?: boolean
}
const ModelTrigger: FC<ModelTriggerProps> = ({
open,
provider,
model,
className,
readonly,
}) => {
const { t } = useTranslation()
const isActive = model.status === ModelStatusEnum.active
const statusI18nKey = STATUS_I18N_KEY[model.status]
return (
<div
className={cn(
'group flex h-8 items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1',
!readonly && 'cursor-pointer hover:bg-components-input-bg-hover',
open && 'bg-components-input-bg-hover',
!isActive && 'bg-components-input-bg-disabled hover:bg-components-input-bg-disabled',
className,
)}
>
<ModelIcon
className="p-0.5"
provider={provider}
modelName={model.model}
/>
<div className="flex grow items-center gap-1 truncate px-1 py-[3px]">
<ModelName
className="grow"
modelItem={model}
showMode
showFeatures
/>
{!readonly && !isActive && statusI18nKey && (
<Tooltip>
<TooltipTrigger
disabled={model.status !== ModelStatusEnum.noPermission}
render={(
<div className="flex shrink-0 items-center gap-[3px] rounded-md border border-text-warning px-[5px] py-0.5">
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
<span className="whitespace-nowrap text-text-warning system-xs-medium">
{t(statusI18nKey as 'modelProvider.selector.creditsExhausted', { ns: 'common' })}
</span>
</div>
)}
/>
<TooltipContent placement="top" className="z-[1003]">
{t('modelProvider.selector.incompatibleTip', { ns: 'common' })}
</TooltipContent>
</Tooltip>
)}
{!readonly && isActive && (
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5 shrink-0 text-text-tertiary" />
)}
</div>
</div>
)
}
export default ModelTrigger

View File

@ -7,12 +7,12 @@ import type {
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import Tooltip from '@/app/components/base/tooltip'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
@ -114,7 +114,7 @@ const PopupItem: FC<PopupItemProps> = ({
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger
render={(
<div className="flex cursor-pointer items-center text-text-tertiary system-xs-medium">
<div className="flex cursor-pointer items-center rounded-md px-1.5 py-1 text-text-tertiary system-xs-medium hover:bg-components-button-ghost-bg-hover">
{isUsingCredits
? (
hasCredits
@ -144,10 +144,16 @@ const PopupItem: FC<PopupItemProps> = ({
<span className="ml-1 text-text-tertiary">{t('modelProvider.selector.configureRequired', { ns: 'common' })}</span>
</>
)}
<span className={cn('i-ri-arrow-down-s-line !h-[14px] !w-[14px] translate-y-px text-text-tertiary', collapsed && '-rotate-90')} />
<span className="i-ri-arrow-down-s-line !h-[14px] !w-[14px] translate-y-px text-text-tertiary" />
</div>
)}
/>
{/*
* TODO(overlay-migration): temporary layering hack.
* This nested provider-settings dropdown opens from inside ModelSelector
* (z-[1002]), so it must stay one level higher until all related modals
* are unified on the new overlay stack.
*/}
<PopoverContent placement="bottom-end" className="z-[1003]">
<DropdownContent
provider={currentProvider}
@ -160,11 +166,47 @@ const PopupItem: FC<PopupItemProps> = ({
</Popover>
</div>
{!collapsed && model.models.map(modelItem => (
<Tooltip
key={modelItem.model}
position="right"
popupClassName="p-3 !w-[206px] bg-components-panel-bg-blur backdrop-blur-sm border-[0.5px] border-components-panel-border rounded-xl"
popupContent={(
<Tooltip key={modelItem.model}>
<TooltipTrigger
render={(
<div
className={cn('group relative flex h-8 items-center gap-1 rounded-lg px-3 py-1.5', modelItem.status === ModelStatusEnum.active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed hover:bg-state-base-hover-alt')}
onClick={() => handleSelect(model.provider, modelItem)}
>
<div className="flex items-center gap-2">
<ModelIcon
className={cn('h-5 w-5 shrink-0')}
provider={model}
modelName={modelItem.model}
/>
<ModelName
className={cn('text-text-secondary system-sm-medium', modelItem.status !== ModelStatusEnum.active && 'opacity-60')}
modelItem={modelItem}
/>
</div>
{
defaultModel?.model === modelItem.model && defaultModel.provider === currentProvider.provider && (
<span className="i-custom-vender-line-general-check h-4 w-4 shrink-0 text-text-accent" />
)
}
{
modelItem.status === ModelStatusEnum.noConfigure && (
<div
className="hidden cursor-pointer text-xs font-medium text-text-accent group-hover:block"
onClick={handleOpenModelModal}
>
{t('operation.add', { ns: 'common' }).toLocaleUpperCase()}
</div>
)
}
</div>
)}
/>
<TooltipContent
placement="right"
variant="plain"
popupClassName="w-[206px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 backdrop-blur-sm"
>
<div className="flex flex-col gap-1">
<div className="flex flex-col items-start gap-2">
<ModelIcon
@ -174,9 +216,6 @@ const PopupItem: FC<PopupItemProps> = ({
/>
<div className="text-wrap break-words text-text-primary system-md-medium">{modelItem.label[language] || modelItem.label.en_US}</div>
</div>
{/* {currentProvider?.description && (
<div className='text-text-tertiary system-xs-regular'>{currentProvider?.description?.[language] || currentProvider?.description?.en_US}</div>
)} */}
<div className="flex flex-wrap gap-1">
{!!modelItem.model_type && (
<ModelBadge>
@ -211,40 +250,7 @@ const PopupItem: FC<PopupItemProps> = ({
</div>
)}
</div>
)}
>
<div
key={modelItem.model}
className={cn('group relative flex h-8 items-center gap-1 rounded-lg px-3 py-1.5', modelItem.status === ModelStatusEnum.active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed hover:bg-state-base-hover-alt')}
onClick={() => handleSelect(model.provider, modelItem)}
>
<div className="flex items-center gap-2">
<ModelIcon
className={cn('h-5 w-5 shrink-0')}
provider={model}
modelName={modelItem.model}
/>
<ModelName
className={cn('text-text-secondary system-sm-medium', modelItem.status !== ModelStatusEnum.active && 'opacity-60')}
modelItem={modelItem}
/>
</div>
{
defaultModel?.model === modelItem.model && defaultModel.provider === currentProvider.provider && (
<span className="i-custom-vender-line-general-check h-4 w-4 shrink-0 text-text-accent" />
)
}
{
modelItem.status === ModelStatusEnum.noConfigure && (
<div
className="hidden cursor-pointer text-xs font-medium text-text-accent group-hover:block"
onClick={handleOpenModelModal}
>
{t('operation.add', { ns: 'common' }).toLocaleUpperCase()}
</div>
)
}
</div>
</TooltipContent>
</Tooltip>
))}
</div>

View File

@ -1,4 +1,4 @@
import type { FC, RefObject } from 'react'
import type { FC } from 'react'
import type {
DefaultModel,
Model,
@ -6,10 +6,9 @@ import type {
} from '../declarations'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { useTheme } from 'next-themes'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { tooltipManager } from '@/app/components/base/tooltip/TooltipManager'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
@ -33,7 +32,6 @@ type PopupProps = {
onSelect: (provider: string, model: ModelItem) => void
scopeFeatures?: ModelFeatureEnum[]
onHide: () => void
triggerRef?: RefObject<HTMLDivElement | null>
}
const Popup: FC<PopupProps> = ({
defaultModel,
@ -41,7 +39,6 @@ const Popup: FC<PopupProps> = ({
onSelect,
scopeFeatures = [],
onHide,
triggerRef,
}) => {
const { t } = useTranslation()
const { theme } = useTheme()
@ -50,9 +47,6 @@ const Popup: FC<PopupProps> = ({
const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false)
const { setShowAccountSettingModal } = useModalContext()
const { modelProviders } = useProviderContext()
const scrollRef = useRef<HTMLDivElement>(null)
const triggerWidth = triggerRef?.current?.offsetWidth
const {
plugins: allPlugins,
} = useMarketplaceAllPlugins(modelProviders, '')
@ -95,25 +89,6 @@ const Popup: FC<PopupProps> = ({
}
}, [allPlugins, installingProvider, installPackageFromMarketPlace, refreshPluginList])
// Close any open tooltips when the user scrolls to prevent them from appearing
// in incorrect positions or becoming detached from their trigger elements
useEffect(() => {
const handleTooltipCloseOnScroll = () => {
tooltipManager.closeActiveTooltip()
}
const scrollContainer = scrollRef.current
if (!scrollContainer)
return
// Use passive listener for better performance since we don't prevent default
scrollContainer.addEventListener('scroll', handleTooltipCloseOnScroll, { passive: true })
return () => {
scrollContainer.removeEventListener('scroll', handleTooltipCloseOnScroll)
}
}, [])
const filteredModelList = useMemo(() => {
const filtered = modelList.map((model) => {
const filteredModels = model.models
@ -153,7 +128,7 @@ const Popup: FC<PopupProps> = ({
}, [modelList])
return (
<div ref={scrollRef} className="max-h-[480px] min-w-[320px] overflow-y-auto rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg no-scrollbar" style={triggerWidth ? { width: triggerWidth } : undefined}>
<div className="max-h-[480px] overflow-y-auto no-scrollbar">
<div className="sticky top-0 z-10 bg-components-panel-bg pb-1 pl-3 pr-2 pt-3">
<div className={`
flex h-8 items-center rounded-lg border pl-[9px] pr-[10px]

View File

@ -10,12 +10,12 @@ import type {
import type { TriggerProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Toast from '@/app/components/base/toast'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
useModelList,
@ -187,99 +187,101 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
}
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement={isInWorkflow ? 'left' : 'bottom-end'}
offset={4}
onOpenChange={(newOpen) => {
if (readonly)
return
setOpen(newOpen)
}}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => {
if (readonly)
return
setOpen(v => !v)
}}
className="block"
>
{
renderTrigger
? renderTrigger({
open,
disabled,
modelDisabled,
hasDeprecated,
currentProvider,
currentModel,
providerName: value?.provider,
modelId: value?.model,
})
: (isAgentStrategy
? (
<AgentModelTrigger
disabled={disabled}
hasDeprecated={hasDeprecated}
currentProvider={currentProvider}
currentModel={currentModel}
providerName={value?.provider}
modelId={value?.model}
scope={scope}
/>
<PopoverTrigger
render={(
<div className="block">
{
renderTrigger
? renderTrigger({
open,
disabled,
modelDisabled,
hasDeprecated,
currentProvider,
currentModel,
providerName: value?.provider,
modelId: value?.model,
})
: (isAgentStrategy
? (
<AgentModelTrigger
disabled={disabled}
hasDeprecated={hasDeprecated}
currentProvider={currentProvider}
currentModel={currentModel}
providerName={value?.provider}
modelId={value?.model}
scope={scope}
/>
)
: (
<Trigger
disabled={disabled}
isInWorkflow={isInWorkflow}
modelDisabled={modelDisabled}
hasDeprecated={hasDeprecated}
currentProvider={currentProvider}
currentModel={currentModel}
providerName={value?.provider}
modelId={value?.model}
/>
)
)
: (
<Trigger
disabled={disabled}
isInWorkflow={isInWorkflow}
modelDisabled={modelDisabled}
hasDeprecated={hasDeprecated}
currentProvider={currentProvider}
currentModel={currentModel}
providerName={value?.provider}
modelId={value?.model}
/>
)
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={cn('z-50', portalToFollowElemContentClassName)}>
<div className={cn(popupClassName, 'w-[389px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg')}>
<div className={cn('max-h-[420px] overflow-y-auto p-4 pt-3')}>
<div className="relative">
<div className={cn('system-sm-semibold mb-1 flex h-6 items-center text-text-secondary')}>
{t('modelProvider.model', { ns: 'common' }).toLocaleUpperCase()}
</div>
<ModelSelector
defaultModel={(value?.provider || value?.model) ? { provider: value?.provider, model: value?.model } : undefined}
modelList={scopedModelList}
scopeFeatures={scopeFeatures}
onSelect={handleChangeModel}
/>
</div>
{(currentModel?.model_type === ModelTypeEnum.textGeneration || currentModel?.model_type === ModelTypeEnum.tts) && (
<div className="my-3 h-px bg-divider-subtle" />
)}
{currentModel?.model_type === ModelTypeEnum.textGeneration && (
<LLMParamsPanel
provider={value?.provider}
modelId={value?.model}
completionParams={value?.completion_params || {}}
onCompletionParamsChange={handleLLMParamsChange}
isAdvancedMode={isAdvancedMode}
/>
)}
{currentModel?.model_type === ModelTypeEnum.tts && (
<TTSParamsPanel
currentModel={currentModel}
language={value?.language}
voice={value?.voice}
onChange={handleTTSParamsChange}
/>
)}
}
</div>
)}
/>
<PopoverContent
placement={isInWorkflow ? 'left' : 'bottom-end'}
sideOffset={4}
className={portalToFollowElemContentClassName}
popupClassName={cn(popupClassName, 'w-[389px] rounded-2xl')}
>
<div className="max-h-[420px] overflow-y-auto p-4 pt-3">
<div className="relative">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">
{t('modelProvider.model', { ns: 'common' }).toLocaleUpperCase()}
</div>
<ModelSelector
defaultModel={(value?.provider || value?.model) ? { provider: value?.provider, model: value?.model } : undefined}
modelList={scopedModelList}
scopeFeatures={scopeFeatures}
onSelect={handleChangeModel}
/>
</div>
{(currentModel?.model_type === ModelTypeEnum.textGeneration || currentModel?.model_type === ModelTypeEnum.tts) && (
<div className="my-3 h-px bg-divider-subtle" />
)}
{currentModel?.model_type === ModelTypeEnum.textGeneration && (
<LLMParamsPanel
provider={value?.provider}
modelId={value?.model}
completionParams={value?.completion_params || {}}
onCompletionParamsChange={handleLLMParamsChange}
isAdvancedMode={isAdvancedMode}
/>
)}
{currentModel?.model_type === ModelTypeEnum.tts && (
<TTSParamsPanel
currentModel={currentModel}
language={value?.language}
voice={value?.voice}
onChange={handleTTSParamsChange}
/>
)}
</div>
</PortalToFollowElemContent>
</PopoverContent>
</div>
</PortalToFollowElem>
</Popover>
)
}

View File

@ -800,12 +800,6 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"tailwindcss/no-unnecessary-whitespace": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@ -4182,9 +4176,6 @@
"app/components/datasets/settings/summary-index-setting.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 11
}
},
"app/components/develop/code.tsx": {
@ -4655,11 +4646,6 @@
"count": 2
}
},
"app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
@ -4700,34 +4686,11 @@
"count": 2
}
},
"app/components/header/account-setting/model-provider-page/model-selector/deprecated-model-trigger.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/header/account-setting/model-provider-page/model-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/header/account-setting/model-provider-page/model-selector/popup.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx": {
"no-restricted-imports": {
"count": 1
@ -5211,12 +5174,6 @@
}
},
"app/components/plugins/plugin-detail-panel/model-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}