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": "開啟目錄",