From 191a4c5cc516e9804806dd6448e78aa9439e7ec1 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Mon, 25 May 2026 01:18:06 +0800 Subject: [PATCH] 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 --- .../sheets/forms/BaseProviderForm.tsx | 96 ++++++++- .../sheets/forms/ModelDiscoveryPanel.tsx | 203 ++++++++++++++++++ .../sheets/forms/sharedForm.module.scss | 164 ++++++++++++++ .../sheets/forms/useModelDiscovery.ts | 127 +++++++++++ src/i18n/locales/en.json | 14 ++ src/i18n/locales/ru.json | 14 ++ src/i18n/locales/zh-CN.json | 14 ++ src/i18n/locales/zh-TW.json | 14 ++ 8 files changed, 644 insertions(+), 2 deletions(-) create mode 100644 src/features/providers/sheets/forms/ModelDiscoveryPanel.tsx create mode 100644 src/features/providers/sheets/forms/useModelDiscovery.ts diff --git a/src/features/providers/sheets/forms/BaseProviderForm.tsx b/src/features/providers/sheets/forms/BaseProviderForm.tsx index 0ba8c0a..4bd1729 100644 --- a/src/features/providers/sheets/forms/BaseProviderForm.tsx +++ b/src/features/providers/sheets/forms/BaseProviderForm.tsx @@ -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(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( @@ -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(); + 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(); + 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 = ( key: K, value: ProviderEntryFormInput[K] @@ -723,6 +787,34 @@ export function BaseProviderForm({ {descriptor.supportsModels ? (
+ {discovery.available ? ( +
+ +
+ ) : null} + {discovery.available && discoveryOpen ? ( + { + applyDiscoveredModels(names); + }} + onReload={() => void discovery.fetch()} + onClose={closeDiscovery} + /> + ) : null} {modelsList.map((entry, idx) => (
; + 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>(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 ( +
+
+
+ + setSearch(e.target.value)} + placeholder={t('providersPage.discovery.searchPlaceholder')} + /> +
+ +
+ + {loading && !models.length ? ( +
+ {t('providersPage.discovery.loading')} +
+ ) : error ? ( +
{error}
+ ) : hasFetched && !models.length ? ( +
+ {t('providersPage.discovery.empty')} +
+ ) : models.length ? ( + <> +
+ + {allSelectableChecked + ? t('providersPage.discovery.clearAll') + : t('providersPage.discovery.selectAll')} + + } + /> + + {t('providersPage.discovery.selectedCount', { + selected: selected.size, + total: selectable.length, + })} + +
+
    + {filtered.map((m) => { + const existing = existingNames.has(m.name); + return ( +
  • + {existing ? ( + <> + {m.name} + + {t('providersPage.discovery.alreadyAdded')} + + + ) : ( + toggle(m.name)} + label={ + {m.name} + } + /> + )} +
  • + ); + })} +
+ + ) : ( +
+ {t('providersPage.discovery.notLoaded')} +
+ )} + +
+ + +
+
+ ); +} diff --git a/src/features/providers/sheets/forms/sharedForm.module.scss b/src/features/providers/sheets/forms/sharedForm.module.scss index 81dff26..495a2de 100644 --- a/src/features/providers/sheets/forms/sharedForm.module.scss +++ b/src/features/providers/sheets/forms/sharedForm.module.scss @@ -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); diff --git a/src/features/providers/sheets/forms/useModelDiscovery.ts b/src/features/providers/sheets/forms/useModelDiscovery.ts new file mode 100644 index 0000000..ce320e8 --- /dev/null +++ b/src/features/providers/sheets/forms/useModelDiscovery.ts @@ -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 = [ + 'gemini', + 'codex', + 'claude', + 'openaiCompatibility', +]; + +export const isModelDiscoveryBrand = (brand: ProviderBrand): boolean => + MODEL_DISCOVERY_BRANDS.includes(brand); + +const parseHeadersText = (text: string): Record => { + const out: Record = {}; + 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; + 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(null); + const [models, setModels] = useState([]); + 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 }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 214519e..74bd281 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 29adae1..4b04ffe 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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": "Открыть", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 461fd46..4122f21 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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": "打开目录", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index f96e048..2579b92 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -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": "開啟目錄",