feat(providers): add model discovery panel inside the models section

- New useModelDiscovery hook fans out to modelsApi.fetchGemini /
  fetchV1 / fetchClaude / fetchModels based on brand and surfaces
  loading, error and the normalized model list
- New ModelDiscoveryPanel renders an inline search, select-all and
  per-model checkboxes with an Already added marker for entries that
  already exist in form.models
- BaseProviderForm: show a Fetch from endpoint button at the top of
  the models section for the supported brands, with apply merging
  the picked names into form.models while skipping duplicates and
  replacing the empty placeholder row
- Add i18n keys (en/zh-CN/zh-TW/ru) for the discovery toolbar, list
  states and apply count
This commit is contained in:
LTbinglingfeng
2026-05-25 01:18:06 +08:00
parent 4711db9bbb
commit 191a4c5cc5
8 changed files with 644 additions and 2 deletions
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import {
IconAlertTriangle,
IconCheckCircle2,
IconDownload,
IconLoader2,
IconPlus,
IconX,
@@ -27,6 +28,8 @@ import {
type ConnectivityErrorMessages,
type ConnectivityState,
} from './useConnectivityTest';
import { useModelDiscovery } from './useModelDiscovery';
import { ModelDiscoveryPanel } from './ModelDiscoveryPanel';
import styles from './sharedForm.module.scss';
export interface BaseProviderFormHandle {
@@ -202,8 +205,9 @@ export function BaseProviderForm({
const [error, setError] = useState<string | null>(null);
const fallbackApiKey = useMemo(() => {
if (brand !== 'claude' || mode !== 'edit' || !resource) return '';
return (resource.raw as ProviderKeyConfig | undefined)?.apiKey ?? '';
if (mode !== 'edit' || !resource) return '';
if (brand === 'openaiCompatibility') return '';
return (resource.raw as { apiKey?: string } | undefined)?.apiKey ?? '';
}, [brand, mode, resource]);
const connectivityMessages = useMemo<ConnectivityErrorMessages>(
@@ -233,6 +237,66 @@ export function BaseProviderForm({
connectivityMessages
);
const discovery = useModelDiscovery({
brand,
baseUrl: form.baseUrl,
formHeaders: form.headers,
apiKeyEntries: form.apiKeyEntries,
apiKey: form.apiKey,
fallbackApiKey,
});
const [discoveryOpen, setDiscoveryOpen] = useState(false);
const existingModelNames = useMemo(() => {
const set = new Set<string>();
form.models.forEach((m) => {
const name = (m.name ?? '').trim();
if (name) set.add(name);
});
return set;
}, [form.models]);
const openDiscovery = () => {
setDiscoveryOpen(true);
if (!discovery.loading && !discovery.hasFetched) {
void discovery.fetch();
}
};
const closeDiscovery = () => {
setDiscoveryOpen(false);
};
const applyDiscoveredModels = (names: string[]) => {
if (!names.length) return;
setForm((prev) => {
const seen = new Set<string>();
const next: ModelEntryInput[] = [];
prev.models.forEach((entry) => {
const trimmed = (entry.name ?? '').trim();
if (trimmed) {
if (seen.has(trimmed)) return;
seen.add(trimmed);
}
next.push(entry);
});
// If the existing list is just an empty placeholder row, drop it.
const placeholderIdx = next.findIndex(
(it) => !(it.name ?? '').trim() && !(it.alias ?? '').trim()
);
if (placeholderIdx !== -1 && names.length > 0) {
next.splice(placeholderIdx, 1);
}
names.forEach((name) => {
const trimmed = name.trim();
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
next.push({ name: trimmed, alias: '' });
});
return { ...prev, models: next };
});
};
const updateField = <K extends keyof ProviderEntryFormInput>(
key: K,
value: ProviderEntryFormInput[K]
@@ -723,6 +787,34 @@ export function BaseProviderForm({
{descriptor.supportsModels ? (
<Collapsible label={t('providersPage.form.modelsSection')}>
<div className={styles.entriesList}>
{discovery.available ? (
<div className={styles.entriesToolbar}>
<button
type="button"
className={styles.connectivityBtn}
onClick={openDiscovery}
disabled={mutating}
>
<IconDownload size={14} />
<span>{t('providersPage.discovery.openButton')}</span>
</button>
</div>
) : null}
{discovery.available && discoveryOpen ? (
<ModelDiscoveryPanel
loading={discovery.loading}
error={discovery.error}
models={discovery.models}
hasFetched={discovery.hasFetched}
existingNames={existingModelNames}
mutating={mutating}
onApply={(names) => {
applyDiscoveredModels(names);
}}
onReload={() => void discovery.fetch()}
onClose={closeDiscovery}
/>
) : null}
{modelsList.map((entry, idx) => (
<div
key={idx}
@@ -0,0 +1,203 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
IconLoader2,
IconRefreshCw,
IconSearch,
} from '@/components/ui/icons';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import type { ModelInfo } from '@/utils/models';
import styles from './sharedForm.module.scss';
interface ModelDiscoveryPanelProps {
loading: boolean;
error: string | null;
models: ModelInfo[];
hasFetched: boolean;
existingNames: Set<string>;
mutating?: boolean;
onApply: (names: string[]) => void;
onReload: () => void;
onClose: () => void;
}
export function ModelDiscoveryPanel({
loading,
error,
models,
hasFetched,
existingNames,
mutating,
onApply,
onReload,
onClose,
}: ModelDiscoveryPanelProps) {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return models;
return models.filter((m) =>
`${m.name} ${m.alias ?? ''}`.toLowerCase().includes(q)
);
}, [models, search]);
const selectable = useMemo(
() => filtered.filter((m) => !existingNames.has(m.name)),
[filtered, existingNames]
);
const allSelectableChecked =
selectable.length > 0 && selectable.every((m) => selected.has(m.name));
const toggle = (name: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
};
const toggleAll = () => {
if (allSelectableChecked) {
setSelected(new Set());
} else {
setSelected(new Set(selectable.map((m) => m.name)));
}
};
const handleApply = () => {
const names = Array.from(selected).filter((n) => !existingNames.has(n));
if (!names.length) return;
onApply(names);
setSelected(new Set());
};
return (
<div className={styles.discoveryPanel}>
<div className={styles.discoveryToolbar}>
<div className={styles.discoverySearchWrap}>
<span className={styles.discoverySearchIcon} aria-hidden="true">
<IconSearch size={14} />
</span>
<input
type="search"
className={styles.discoverySearch}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('providersPage.discovery.searchPlaceholder')}
/>
</div>
<button
type="button"
className={styles.connectivityBtn}
onClick={onReload}
disabled={loading}
aria-label={t('providersPage.discovery.reload')}
>
{loading ? (
<span className={`${styles.statusIcon} ${styles.statusIconLoading}`}>
<IconLoader2 size={14} />
</span>
) : (
<IconRefreshCw size={14} />
)}
<span>{t('providersPage.discovery.reload')}</span>
</button>
</div>
{loading && !models.length ? (
<div className={styles.discoveryEmpty}>
{t('providersPage.discovery.loading')}
</div>
) : error ? (
<div className={styles.connectivityError}>{error}</div>
) : hasFetched && !models.length ? (
<div className={styles.discoveryEmpty}>
{t('providersPage.discovery.empty')}
</div>
) : models.length ? (
<>
<div className={styles.discoveryBatchRow}>
<SelectionCheckbox
checked={allSelectableChecked}
onChange={toggleAll}
disabled={selectable.length === 0}
label={
<span className={styles.discoveryBatchLabel}>
{allSelectableChecked
? t('providersPage.discovery.clearAll')
: t('providersPage.discovery.selectAll')}
</span>
}
/>
<span className={styles.discoveryCount}>
{t('providersPage.discovery.selectedCount', {
selected: selected.size,
total: selectable.length,
})}
</span>
</div>
<ul className={styles.discoveryList}>
{filtered.map((m) => {
const existing = existingNames.has(m.name);
return (
<li
key={m.name}
className={
existing
? `${styles.discoveryItem} ${styles.discoveryItemExisting}`
: styles.discoveryItem
}
>
{existing ? (
<>
<span className={styles.discoveryName}>{m.name}</span>
<span className={styles.discoveryAddedTag}>
{t('providersPage.discovery.alreadyAdded')}
</span>
</>
) : (
<SelectionCheckbox
checked={selected.has(m.name)}
onChange={() => toggle(m.name)}
label={
<span className={styles.discoveryName}>{m.name}</span>
}
/>
)}
</li>
);
})}
</ul>
</>
) : (
<div className={styles.discoveryEmpty}>
{t('providersPage.discovery.notLoaded')}
</div>
)}
<div className={styles.discoveryFooter}>
<button
type="button"
className={styles.connectivityBtnGhost}
onClick={onClose}
disabled={mutating}
>
{t('providersPage.discovery.close')}
</button>
<button
type="button"
className={styles.discoveryApplyBtn}
onClick={handleApply}
disabled={mutating || selected.size === 0}
>
{t('providersPage.discovery.apply', { count: selected.size })}
</button>
</div>
</div>
);
}
@@ -317,6 +317,170 @@
color: var(--destructive-color);
}
.discoveryPanel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-secondary);
}
.discoveryToolbar {
display: flex;
align-items: center;
gap: 8px;
}
.discoverySearchWrap {
position: relative;
flex: 1;
display: flex;
align-items: center;
}
.discoverySearchIcon {
position: absolute;
left: 10px;
display: inline-flex;
align-items: center;
color: var(--muted-foreground);
pointer-events: none;
}
.discoverySearch {
width: 100%;
height: 32px;
padding: 6px 10px 6px 30px;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 12px;
box-sizing: border-box;
&::placeholder {
color: var(--text-tertiary);
}
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px var(--primary-10);
}
}
.discoveryEmpty {
padding: 16px;
text-align: center;
font-size: 12px;
color: var(--muted-foreground);
border: 1px dashed var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-primary);
}
.discoveryBatchRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px;
}
.discoveryBatchLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
}
.discoveryCount {
font-size: 11px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.discoveryList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
max-height: 240px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-primary);
}
.discoveryItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--border-color);
font-size: 12px;
color: var(--text-primary);
&:last-child {
border-bottom: 0;
}
}
.discoveryItemExisting {
background: var(--muted-bg);
color: var(--muted-foreground);
}
.discoveryName {
font-family: $font-mono;
font-size: 12px;
word-break: break-all;
}
.discoveryAddedTag {
font-size: 11px;
color: var(--muted-foreground);
padding: 2px 8px;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-primary);
}
.discoveryFooter {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.discoveryApplyBtn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 14px;
border-radius: var(--radius-md);
border: 1px solid transparent;
background: var(--primary-color);
color: var(--primary-contrast);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background-color $transition-fast;
&:hover:not(:disabled) {
background: var(--primary-hover);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
@@ -0,0 +1,127 @@
import { useCallback, useState } from 'react';
import { modelsApi } from '@/services/api';
import { buildHeaderObject } from '@/utils/headers';
import type { ModelInfo } from '@/utils/models';
import type { ApiKeyEntryInput, ProviderBrand } from '../../types';
export const MODEL_DISCOVERY_BRANDS: ReadonlyArray<ProviderBrand> = [
'gemini',
'codex',
'claude',
'openaiCompatibility',
];
export const isModelDiscoveryBrand = (brand: ProviderBrand): boolean =>
MODEL_DISCOVERY_BRANDS.includes(brand);
const parseHeadersText = (text: string): Record<string, string> => {
const out: Record<string, string> = {};
String(text ?? '')
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const sep = line.indexOf(':');
if (sep <= 0) return;
const key = line.slice(0, sep).trim();
const value = line.slice(sep + 1).trim();
if (!key) return;
out[key] = value;
});
return out;
};
const toErrorMessage = (err: unknown): string => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
export interface UseModelDiscoveryArgs {
brand: ProviderBrand;
baseUrl: string;
formHeaders: Array<{ key: string; value: string }>;
apiKeyEntries?: ApiKeyEntryInput[];
apiKey?: string;
fallbackApiKey?: string;
}
export interface UseModelDiscoveryResult {
available: boolean;
loading: boolean;
error: string | null;
models: ModelInfo[];
hasFetched: boolean;
fetch: () => Promise<void>;
reset: () => void;
}
export function useModelDiscovery(
args: UseModelDiscoveryArgs
): UseModelDiscoveryResult {
const { brand, baseUrl, formHeaders, apiKeyEntries, apiKey, fallbackApiKey } =
args;
const available = isModelDiscoveryBrand(brand);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [models, setModels] = useState<ModelInfo[]>([]);
const [hasFetched, setHasFetched] = useState(false);
const fetch = useCallback(async () => {
if (!available) return;
setLoading(true);
setError(null);
try {
const baseHeaders = buildHeaderObject(formHeaders);
let next: ModelInfo[] = [];
if (brand === 'gemini') {
const key = (apiKey ?? '').trim() || (fallbackApiKey ?? '').trim();
next = await modelsApi.fetchGeminiModelsViaApiCall(
baseUrl,
key,
baseHeaders
);
} else if (brand === 'codex') {
const key = (apiKey ?? '').trim() || (fallbackApiKey ?? '').trim();
next = await modelsApi.fetchV1ModelsViaApiCall(
baseUrl,
key,
baseHeaders
);
} else if (brand === 'claude') {
const key = (apiKey ?? '').trim() || (fallbackApiKey ?? '').trim();
next = await modelsApi.fetchClaudeModelsViaApiCall(
baseUrl,
key,
baseHeaders
);
} else if (brand === 'openaiCompatibility') {
const firstEntry = (apiKeyEntries ?? []).find((e) =>
(e.apiKey ?? '').trim()
);
const entryKey = (firstEntry?.apiKey ?? '').trim();
const entryHeaders = parseHeadersText(firstEntry?.headersText ?? '');
const headers = { ...baseHeaders, ...entryHeaders };
next = await modelsApi.fetchModelsViaApiCall(baseUrl, entryKey, headers);
}
setModels(next ?? []);
setHasFetched(true);
} catch (err) {
setModels([]);
setError(toErrorMessage(err) || 'Failed to fetch models');
setHasFetched(true);
} finally {
setLoading(false);
}
}, [available, apiKey, apiKeyEntries, baseUrl, brand, fallbackApiKey, formHeaders]);
const reset = useCallback(() => {
setModels([]);
setError(null);
setLoading(false);
setHasFetched(false);
}, []);
return { available, loading, error, models, hasFetched, fetch, reset };
}
+14
View File
@@ -1611,6 +1611,20 @@
"timeout": "Timed out after {{seconds}}s",
"requestFailed": "Request failed"
},
"discovery": {
"openButton": "Fetch from endpoint",
"searchPlaceholder": "Search models",
"reload": "Reload",
"loading": "Loading models…",
"empty": "No models returned by the endpoint",
"notLoaded": "Click Reload to fetch models",
"selectAll": "Select all",
"clearAll": "Clear selection",
"selectedCount": "{{selected}} / {{total}}",
"alreadyAdded": "Already added",
"close": "Close",
"apply": "Apply ({{count}})"
},
"modelCatalog": {
"summaryTitle": "Model catalog",
"openAction": "Open catalog",
+14
View File
@@ -1608,6 +1608,20 @@
"timeout": "Таймаут после {{seconds}} с",
"requestFailed": "Запрос не выполнен"
},
"discovery": {
"openButton": "Получить с конечной точки",
"searchPlaceholder": "Поиск моделей",
"reload": "Обновить",
"loading": "Загрузка моделей…",
"empty": "Конечная точка не вернула моделей",
"notLoaded": "Нажмите Обновить, чтобы загрузить модели",
"selectAll": "Выбрать все",
"clearAll": "Снять выбор",
"selectedCount": "{{selected}} / {{total}}",
"alreadyAdded": "Уже добавлено",
"close": "Закрыть",
"apply": "Применить ({{count}})"
},
"modelCatalog": {
"summaryTitle": "Каталог моделей",
"openAction": "Открыть",
+14
View File
@@ -1611,6 +1611,20 @@
"timeout": "请求超过 {{seconds}} 秒",
"requestFailed": "请求失败"
},
"discovery": {
"openButton": "从端点拉取",
"searchPlaceholder": "搜索模型",
"reload": "重新加载",
"loading": "正在加载模型…",
"empty": "端点未返回任何模型",
"notLoaded": "点击重新加载以获取模型",
"selectAll": "全选",
"clearAll": "清空选择",
"selectedCount": "{{selected}} / {{total}}",
"alreadyAdded": "已添加",
"close": "关闭",
"apply": "应用 ({{count}})"
},
"modelCatalog": {
"summaryTitle": "模型目录",
"openAction": "打开目录",
+14
View File
@@ -1637,6 +1637,20 @@
"timeout": "請求超過 {{seconds}} 秒",
"requestFailed": "請求失敗"
},
"discovery": {
"openButton": "從端點拉取",
"searchPlaceholder": "搜尋模型",
"reload": "重新載入",
"loading": "正在載入模型…",
"empty": "端點未回傳任何模型",
"notLoaded": "點擊重新載入以取得模型",
"selectAll": "全選",
"clearAll": "清空選擇",
"selectedCount": "{{selected}} / {{total}}",
"alreadyAdded": "已新增",
"close": "關閉",
"apply": "套用 ({{count}})"
},
"modelCatalog": {
"summaryTitle": "模型目錄",
"openAction": "開啟目錄",