Fix: Fix some model provider-related UI issues (#15884)

### What problem does this PR solve?

Fix: Fix some model provider-related UI issues

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
This commit is contained in:
chanx
2026-06-10 14:05:57 +08:00
committed by GitHub
parent 38755c705a
commit c23809a4bd
4 changed files with 82 additions and 109 deletions

View File

@@ -74,6 +74,9 @@ function ProviderCard({
) => void;
}) {
const { data: instances } = useFetchProviderInstances(provider.name);
if (!instances || instances.length <= 0) {
return null;
}
return (
<div
@@ -90,7 +93,6 @@ function ProviderCard({
</div>
</div>
</div>
{/* Instances */}
{instances.length > 0 && (
<div className="border-t border-border-button">

View File

@@ -1,127 +1,73 @@
import { useTranslate } from '@/hooks/common-hooks';
import { IProviderModelItem } from '@/interfaces/request/llm';
import { useMemo } from 'react';
import type { AddCustomModelDialogFields } from './add-custom-model-dialog';
/**
* Allowed values for `IProviderModelItem['model_types']`. Kept in sync
* with the backend model registry; `LabelKey` matches the i18n namespace
* the descriptor below uses for each option.
*/
const MODEL_TYPE_VALUES = [
'chat',
'embedding',
'rerank',
'tts',
'image2text',
'speech2text',
] as const;
/**
* Allowed values for `IProviderModelItem['features']`.
*/
const FEATURE_VALUES = ['tool_call'] as const;
/**
* Descriptor for a single IProviderModelItem property. Each descriptor
* tells the dialog how to render the form input for that property.
*
* - `type` : the form input type
* - `required` : whether the field must be non-empty
* - `min` : for `number` types, the minimum allowed value
* - `options` : for select-style types, the allowed value set
* - `labelKey` : i18n key for the field label
* - `optionLabelKey` : i18n key prefix for each option's label
*/
type ModelFieldDescriptor = {
type: AddCustomModelDialogFields['type'];
required: boolean;
min?: number;
options?: readonly string[];
labelKey: string;
optionLabelKey?: (value: string) => string;
};
/**
* Single source of truth for the custom-model dialog schema. Mirrors
* the shape of `IProviderModelItem` 1:1 — adding a new property to the
* interface means adding an entry here, and the dialog auto-adapts.
*
* `label` and each option's `label` are i18n keys (under the `setting`
* namespace). `useCustomModelFields` resolves them via `t()`.
*/
const MODEL_FIELD_SCHEMA: Record<
keyof IProviderModelItem,
ModelFieldDescriptor
> = {
name: {
export const MODEL_FIELD_SCHEMA: AddCustomModelDialogFields[] = [
{
name: 'name',
label: 'modelName',
type: 'text',
required: true,
labelKey: 'modelName',
defaultValue: '',
},
model_types: {
{
name: 'model_types',
label: 'modelType',
type: 'multi-select',
required: false,
options: MODEL_TYPE_VALUES,
labelKey: 'modelType',
optionLabelKey: (v) => `setting.modelTypes.${v}`,
defaultValue: [],
options: [
{ value: 'chat', label: 'modelTypes.chat' },
{ value: 'embedding', label: 'modelTypes.embedding' },
{ value: 'rerank', label: 'modelTypes.rerank' },
{ value: 'tts', label: 'modelTypes.tts' },
{ value: 'image2text', label: 'modelTypes.image2text' },
{ value: 'speech2text', label: 'modelTypes.speech2text' },
],
},
max_tokens: {
{
name: 'max_tokens',
label: 'modelMaxTokens',
type: 'number',
required: false,
min: 0,
labelKey: 'modelMaxTokens',
defaultValue: 0,
},
features: {
{
name: 'features',
label: 'modelFeatures',
type: 'switch-group',
required: false,
options: FEATURE_VALUES,
labelKey: 'modelFeatures',
optionLabelKey: (v) =>
v === 'tool_call'
? 'modelFeatureToolCall'
: v === 'function_call'
? 'modelFeatureFunctionCall'
: v,
defaultValue: [],
options: [{ value: 'is_tools', label: 'modelFeatureToolCall' }],
},
};
];
/**
* Dialog field schema for adding a custom model. Derived from
* `IProviderModelItem` via `MODEL_FIELD_SCHEMA`, so the form is in
* lockstep with the model interface.
* Dialog field schema for adding a custom model. Returns
* `MODEL_FIELD_SCHEMA` with i18n keys resolved.
*/
export const useCustomModelFields = (): AddCustomModelDialogFields[] => {
const { t } = useTranslate('setting');
return useMemo<AddCustomModelDialogFields[]>(() => {
return (
Object.entries(MODEL_FIELD_SCHEMA) as Array<
[keyof IProviderModelItem, ModelFieldDescriptor]
>
).map(([prop, desc]) => {
const defaultValue =
desc.type === 'number'
? 0
: desc.type === 'multi-select' || desc.type === 'switch-group'
? []
: '';
return {
name: String(prop),
label: t(desc.labelKey),
type: desc.type,
required: desc.required,
defaultValue,
...(desc.min !== undefined ? { min: desc.min } : {}),
...(desc.options
? {
options: desc.options.map((value) => ({
value,
label: t(
desc.optionLabelKey ? desc.optionLabelKey(value) : value,
),
})),
}
: {}),
};
});
}, [t]);
return useMemo<AddCustomModelDialogFields[]>(
() =>
MODEL_FIELD_SCHEMA.map((field) => ({
...field,
label: t(field.label),
options: field.options?.map((opt) => ({
value: opt.value,
label: t(opt.label),
})),
})),
[t],
);
};

View File

@@ -18,7 +18,7 @@ const getIsToolsFromFeatures = (
features: IProviderModelItem['features'],
): boolean | undefined => {
if (!Array.isArray(features)) return undefined;
return features.includes('tool_call') || features.includes('function_call');
return features.includes('is_tools');
};
// Map a fetched list-model item to the request-side IModelInfo shape.

View File

@@ -42,6 +42,7 @@ const ProviderModal = ({
const { instanceNameSet } = useFetchInstanceNameSet(llmFactory);
const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet);
const [verifyResult, setVerifyResult] = useState<VerifyResult | null>(null);
const scrollAnchorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setVerifyResult(null);
return () => {
@@ -49,6 +50,26 @@ const ProviderModal = ({
};
}, [visible]);
// When a verify result comes back, the VerifyButton renders new log
// content below the existing form. Scroll the modal's scrollable area
// to the bottom so the user actually sees the result. We walk up the
// DOM from a ref inside the scrollable container (the Modal renders
// it via a Radix Portal) and use rAF to wait for the new content to
// be laid out before measuring scrollHeight.
useEffect(() => {
if (!verifyResult || !scrollAnchorRef.current) {
return;
}
const scrollContainer =
scrollAnchorRef.current.closest<HTMLElement>('.overflow-y-auto');
if (!scrollContainer) {
return;
}
requestAnimationFrame(() => {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
});
}, [verifyResult]);
// Field config, default values, doc link, and the LIST_MODEL_PROVIDERS
// flag are all derived from the current llmFactory / mode / initialValues.
// `baseUrlRegionMaps` is forwarded to the actions hook so the modal can
@@ -123,9 +144,7 @@ const ProviderModal = ({
max_tokens: item.max_tokens,
extra: item.features
? {
is_tools:
item.features.includes('tool_call') ||
item.features.includes('function_call'),
is_tools: item.features.includes('is_tools'),
}
: undefined,
});
@@ -203,12 +222,18 @@ const ProviderModal = ({
/>
)}
<VerifyButton
onVerify={handleVerify}
verifyCallback={(result: VerifyResult | null) => {
setVerifyResult(result);
}}
/>
<div ref={scrollAnchorRef}>
<VerifyButton
onVerify={handleVerify}
verifyCallback={(result: VerifyResult | null) => {
setVerifyResult(result);
}}
className={cn({
'!flex flex-col ![position:inherit] ':
verifyResult && docLinkText && config.docLink,
})}
/>
</div>
<div
className={
docLinkText