From c89bbd5098ea315e94ca62063535da14fd7c2011 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 24 Jan 2026 15:30:45 +0800 Subject: [PATCH] feat(auth-files): add auth-file model suggestions for OAuth mappings --- src/components/common/PageTransition.tsx | 24 ++- src/components/providers/utils.ts | 2 +- src/i18n/locales/en.json | 18 +- src/i18n/locales/zh-CN.json | 18 +- src/pages/AuthFilesPage.tsx | 235 +++++++++++++++++++++-- src/services/api/models.ts | 2 +- 6 files changed, 257 insertions(+), 42 deletions(-) diff --git a/src/components/common/PageTransition.tsx b/src/components/common/PageTransition.tsx index 3dd107f..6c5c133 100644 --- a/src/components/common/PageTransition.tsx +++ b/src/components/common/PageTransition.tsx @@ -30,10 +30,10 @@ export function PageTransition({ const location = useLocation(); const currentLayerRef = useRef(null); const exitingLayerRef = useRef(null); + const transitionDirectionRef = useRef('forward'); const exitScrollOffsetRef = useRef(0); const [isAnimating, setIsAnimating] = useState(false); - const [transitionDirection, setTransitionDirection] = useState('forward'); const [layers, setLayers] = useState(() => [ { key: location.key, @@ -70,7 +70,7 @@ export function PageTransition({ ? 'forward' : 'backward'; - setTransitionDirection(nextDirection); + transitionDirectionRef.current = nextDirection; setLayers((prev) => { const prevCurrent = prev[prev.length - 1]; return [ @@ -96,12 +96,16 @@ export function PageTransition({ if (!currentLayerRef.current) return; + const currentLayerEl = currentLayerRef.current; + const exitingLayerEl = exitingLayerRef.current; + const scrollContainer = resolveScrollContainer(); const scrollOffset = exitScrollOffsetRef.current; if (scrollContainer && scrollOffset > 0) { scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' }); } + const transitionDirection = transitionDirectionRef.current; const enterFromY = transitionDirection === 'forward' ? TRAVEL_DISTANCE : -TRAVEL_DISTANCE; const exitToY = transitionDirection === 'forward' ? -TRAVEL_DISTANCE : TRAVEL_DISTANCE; const exitBaseY = scrollOffset ? -scrollOffset : 0; @@ -114,10 +118,10 @@ export function PageTransition({ }); // Exit animation: fade out with slight movement (runs simultaneously) - if (exitingLayerRef.current) { - gsap.set(exitingLayerRef.current, { y: exitBaseY }); + if (exitingLayerEl) { + gsap.set(exitingLayerEl, { y: exitBaseY }); tl.to( - exitingLayerRef.current, + exitingLayerEl, { y: exitBaseY + exitToY, opacity: 0, @@ -131,7 +135,7 @@ export function PageTransition({ // Enter animation: fade in with slight movement (runs simultaneously) tl.fromTo( - currentLayerRef.current, + currentLayerEl, { y: enterFromY, opacity: 0 }, { y: 0, @@ -140,8 +144,8 @@ export function PageTransition({ ease: 'circ.out', force3D: true, onComplete: () => { - if (currentLayerRef.current) { - gsap.set(currentLayerRef.current, { clearProps: 'transform,opacity' }); + if (currentLayerEl) { + gsap.set(currentLayerEl, { clearProps: 'transform,opacity' }); } }, }, @@ -150,9 +154,9 @@ export function PageTransition({ return () => { tl.kill(); - gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]); + gsap.killTweensOf([currentLayerEl, exitingLayerEl]); }; - }, [isAnimating, transitionDirection, resolveScrollContainer]); + }, [isAnimating, resolveScrollContainer]); return (
diff --git a/src/components/providers/utils.ts b/src/components/providers/utils.ts index e44bd91..7ba75a2 100644 --- a/src/components/providers/utils.ts +++ b/src/components/providers/utils.ts @@ -46,7 +46,7 @@ export const normalizeOpenAIBaseUrl = (baseUrl: string): string => { export const buildOpenAIModelsEndpoint = (baseUrl: string): string => { const trimmed = normalizeOpenAIBaseUrl(baseUrl); if (!trimmed) return ''; - return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`; + return `${trimmed}/models`; }; export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5525bd4..f424856 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -293,12 +293,12 @@ "openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free", "openai_model_alias_placeholder": "Model alias (optional)", "openai_models_add_btn": "Add Model", - "openai_models_fetch_button": "Fetch via /v1/models", - "openai_models_fetch_title": "Pick Models from /v1/models", - "openai_models_fetch_hint": "Call the /v1/models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.", + "openai_models_fetch_button": "Fetch via /models", + "openai_models_fetch_title": "Pick Models from /models", + "openai_models_fetch_hint": "Call the /models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.", "openai_models_fetch_url_label": "Request URL", "openai_models_fetch_refresh": "Refresh", - "openai_models_fetch_loading": "Fetching models from /v1/models...", + "openai_models_fetch_loading": "Fetching models from /models...", "openai_models_fetch_empty": "No models returned. Please check the endpoint or auth.", "openai_models_fetch_error": "Failed to fetch models", "openai_models_fetch_back": "Back to edit", @@ -519,6 +519,12 @@ "provider_label": "Provider", "provider_placeholder": "e.g. gemini-cli / vertex", "provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.", + "model_source_label": "Auth file model source", + "model_source_placeholder": "Select an auth file (for model suggestions)", + "model_source_hint": "Pick an auth file to enable model suggestions for “Source model name”. You can still type custom values.", + "model_source_loading": "Loading models...", + "model_source_unsupported": "The current CPA version does not support fetching model lists (manual input still works).", + "model_source_loaded": "{{count}} models loaded. Use the dropdown in “Source model name”, or type custom values.", "mappings_label": "Model mappings", "mapping_name_placeholder": "Source model name", "mapping_alias_placeholder": "Alias (required)", @@ -799,9 +805,9 @@ "not_loaded": "Not Loaded", "seconds_ago": "seconds ago", "models_title": "Available Models", - "models_desc": "Shows the /v1/models response and uses saved API keys for auth automatically.", + "models_desc": "Shows the /models response and uses saved API keys for auth automatically.", "models_loading": "Loading available models...", - "models_empty": "No models returned by /v1/models", + "models_empty": "No models returned by /models", "models_error": "Failed to load model list", "models_count": "{{count}} available models", "version_check_title": "Update Check", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f5d88bc..98b64c3 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -293,12 +293,12 @@ "openai_model_name_placeholder": "模型名称,如 moonshotai/kimi-k2:free", "openai_model_alias_placeholder": "模型别名 (可选)", "openai_models_add_btn": "添加模型", - "openai_models_fetch_button": "从 /v1/models 获取", - "openai_models_fetch_title": "从 /v1/models 选择模型", - "openai_models_fetch_hint": "使用上方 Base URL 调用 /v1/models 端点,附带首个 API Key(Bearer)与自定义请求头。", + "openai_models_fetch_button": "从 /models 获取", + "openai_models_fetch_title": "从 /models 选择模型", + "openai_models_fetch_hint": "使用上方 Base URL 调用 /models 端点,附带首个 API Key(Bearer)与自定义请求头。", "openai_models_fetch_url_label": "请求地址", "openai_models_fetch_refresh": "重新获取", - "openai_models_fetch_loading": "正在从 /v1/models 获取模型列表...", + "openai_models_fetch_loading": "正在从 /models 获取模型列表...", "openai_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。", "openai_models_fetch_error": "获取模型失败", "openai_models_fetch_back": "返回编辑", @@ -519,6 +519,12 @@ "provider_label": "提供商", "provider_placeholder": "例如 gemini-cli / vertex", "provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。", + "model_source_label": "模型来源认证文件", + "model_source_placeholder": "选择认证文件(用于原模型下拉建议)", + "model_source_hint": "选择一个认证文件后,“原模型名称”支持下拉选择;也可手动输入自定义模型。", + "model_source_loading": "正在加载模型列表...", + "model_source_unsupported": "当前 CPA 版本不支持获取模型列表(仍可手动输入)。", + "model_source_loaded": "已加载 {{count}} 个模型,可在“原模型名称”中下拉选择;也可手动输入。", "mappings_label": "模型映射", "mapping_name_placeholder": "原模型名称", "mapping_alias_placeholder": "别名 (必填)", @@ -799,9 +805,9 @@ "not_loaded": "未加载", "seconds_ago": "秒前", "models_title": "可用模型列表", - "models_desc": "展示 /v1/models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。", + "models_desc": "展示 /models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。", "models_loading": "正在加载可用模型...", - "models_empty": "未从 /v1/models 获取到模型数据", + "models_empty": "未从 /models 获取到模型数据", "models_error": "获取模型列表失败", "models_count": "可用模型 {{count}} 个", "version_check_title": "版本检查", diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 5b018db..75491d0 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -36,6 +36,7 @@ import styles from './AuthFilesPage.module.scss'; type ThemeColors = { bg: string; text: string; border?: string }; type TypeColorSet = { light: ThemeColors; dark?: ThemeColors }; type ResolvedTheme = 'light' | 'dark'; +type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string }; // 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色) const TYPE_COLORS: Record = { @@ -203,6 +204,7 @@ export function AuthFilesPage() { const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(9); + const [pageSizeInput, setPageSizeInput] = useState('9'); const [uploading, setUploading] = useState(false); const [deleting, setDeleting] = useState(null); const [deletingAll, setDeletingAll] = useState(false); @@ -217,12 +219,11 @@ export function AuthFilesPage() { // 模型列表弹窗相关 const [modelsModalOpen, setModelsModalOpen] = useState(false); const [modelsLoading, setModelsLoading] = useState(false); - const [modelsList, setModelsList] = useState< - { id: string; display_name?: string; type?: string }[] - >([]); + const [modelsList, setModelsList] = useState([]); const [modelsFileName, setModelsFileName] = useState(''); const [modelsFileType, setModelsFileType] = useState(''); const [modelsError, setModelsError] = useState<'unsupported' | null>(null); + const modelsCacheRef = useRef>(new Map()); // OAuth 排除模型相关 const [excluded, setExcluded] = useState>({}); @@ -242,6 +243,10 @@ export function AuthFilesPage() { provider: '', mappings: [buildEmptyMappingEntry()], }); + const [mappingModelsFileName, setMappingModelsFileName] = useState(''); + const [mappingModelsList, setMappingModelsList] = useState([]); + const [mappingModelsLoading, setMappingModelsLoading] = useState(false); + const [mappingModelsError, setMappingModelsError] = useState<'unsupported' | null>(null); const [savingMappings, setSavingMappings] = useState(false); const [prefixProxyEditor, setPrefixProxyEditor] = useState(null); @@ -251,8 +256,105 @@ export function AuthFilesPage() { const excludedUnsupportedRef = useRef(false); const mappingsUnsupportedRef = useRef(false); + const normalizeProviderKey = (value: string) => value.trim().toLowerCase(); + const disableControls = connectionStatus !== 'connected'; + useEffect(() => { + setPageSizeInput(String(pageSize)); + }, [pageSize]); + + const modelSourceFileOptions = useMemo(() => { + const normalizedProvider = normalizeProviderKey(mappingForm.provider); + const matching: string[] = []; + const others: string[] = []; + const seen = new Set(); + + files.forEach((file) => { + const isRuntimeOnly = isRuntimeOnlyAuthFile(file); + const isAistudio = (file.type || '').toLowerCase() === 'aistudio'; + const canShowModels = !isRuntimeOnly || isAistudio; + if (!canShowModels) return; + + const fileName = String(file.name || '').trim(); + if (!fileName) return; + if (seen.has(fileName)) return; + seen.add(fileName); + + if (!normalizedProvider) { + matching.push(fileName); + return; + } + + const typeKey = normalizeProviderKey(String(file.type || '')); + const providerKey = normalizeProviderKey(String(file.provider || '')); + const isMatch = typeKey === normalizedProvider || providerKey === normalizedProvider; + if (isMatch) { + matching.push(fileName); + } else { + others.push(fileName); + } + }); + + matching.sort((a, b) => a.localeCompare(b)); + others.sort((a, b) => a.localeCompare(b)); + return [...matching, ...others]; + }, [files, mappingForm.provider]); + + useEffect(() => { + if (!mappingModalOpen) return; + + const fileName = mappingModelsFileName.trim(); + if (!fileName) { + setMappingModelsList([]); + setMappingModelsError(null); + setMappingModelsLoading(false); + return; + } + + const cached = modelsCacheRef.current.get(fileName); + if (cached) { + setMappingModelsList(cached); + setMappingModelsError(null); + setMappingModelsLoading(false); + return; + } + + let cancelled = false; + setMappingModelsLoading(true); + setMappingModelsError(null); + + authFilesApi + .getModelsForAuthFile(fileName) + .then((models) => { + if (cancelled) return; + modelsCacheRef.current.set(fileName, models); + setMappingModelsList(models); + }) + .catch((err: unknown) => { + if (cancelled) return; + const errorMessage = err instanceof Error ? err.message : ''; + if ( + errorMessage.includes('404') || + errorMessage.includes('not found') || + errorMessage.includes('Not Found') + ) { + setMappingModelsList([]); + setMappingModelsError('unsupported'); + return; + } + showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error'); + }) + .finally(() => { + if (cancelled) return; + setMappingModelsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [mappingModalOpen, mappingModelsFileName, showNotification, t]); + const prefixProxyUpdatedText = useMemo(() => { if (!prefixProxyEditor?.json) return prefixProxyEditor?.rawText ?? ''; const next: Record = { ...prefixProxyEditor.json }; @@ -276,12 +378,39 @@ export function AuthFilesPage() { return prefixProxyUpdatedText !== prefixProxyEditor.originalText; }, [prefixProxyEditor?.json, prefixProxyEditor?.originalText, prefixProxyUpdatedText]); - const normalizeProviderKey = (value: string) => value.trim().toLowerCase(); + const commitPageSizeInput = (rawValue: string) => { + const trimmed = rawValue.trim(); + if (!trimmed) { + setPageSizeInput(String(pageSize)); + return; + } + + const value = Number(trimmed); + if (!Number.isFinite(value)) { + setPageSizeInput(String(pageSize)); + return; + } + + const next = clampCardPageSize(value); + setPageSize(next); + setPageSizeInput(String(next)); + setPage(1); + }; const handlePageSizeChange = (event: React.ChangeEvent) => { - const value = event.currentTarget.valueAsNumber; - if (!Number.isFinite(value)) return; - setPageSize(clampCardPageSize(value)); + const rawValue = event.currentTarget.value; + setPageSizeInput(rawValue); + + const trimmed = rawValue.trim(); + if (!trimmed) return; + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) return; + + const rounded = Math.round(parsed); + if (rounded < MIN_CARD_PAGE_SIZE || rounded > MAX_CARD_PAGE_SIZE) return; + + setPageSize(rounded); setPage(1); }; @@ -835,9 +964,18 @@ export function AuthFilesPage() { setModelsList([]); setModelsError(null); setModelsModalOpen(true); + + const cached = modelsCacheRef.current.get(item.name); + if (cached) { + setModelsList(cached); + setModelsLoading(false); + return; + } + setModelsLoading(true); try { const models = await authFilesApi.getModelsForAuthFile(item.name); + modelsCacheRef.current.set(item.name, models); setModelsList(models); } catch (err) { // 检测是否是 API 不支持的错误 (404 或特定错误消息) @@ -984,10 +1122,30 @@ export function AuthFilesPage() { ? mappingProviderLookup.get(fallbackProvider.toLowerCase()) : undefined; const mappings = lookupKey ? modelMappings[lookupKey] : []; + const providerValue = lookupKey || fallbackProvider; + + const normalizedProviderKey = normalizeProviderKey(providerValue); + const defaultModelsFileName = files + .filter((file) => { + const isRuntimeOnly = isRuntimeOnlyAuthFile(file); + const isAistudio = (file.type || '').toLowerCase() === 'aistudio'; + const canShowModels = !isRuntimeOnly || isAistudio; + if (!canShowModels) return false; + if (!normalizedProviderKey) return false; + const typeKey = normalizeProviderKey(String(file.type || '')); + const providerKey = normalizeProviderKey(String(file.provider || '')); + return typeKey === normalizedProviderKey || providerKey === normalizedProviderKey; + }) + .map((file) => file.name) + .sort((a, b) => a.localeCompare(b))[0]; + setMappingForm({ - provider: lookupKey || fallbackProvider, + provider: providerValue, mappings: normalizeMappingEntries(mappings), }); + setMappingModelsFileName(defaultModelsFileName || ''); + setMappingModelsList([]); + setMappingModelsError(null); setMappingModalOpen(true); }; @@ -1310,7 +1468,15 @@ export function AuthFilesPage() { {t('common.refresh')} + commitPageSizeInput(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + } + }} />
@@ -1815,6 +1979,33 @@ export function AuthFilesPage() { )} +
+ setMappingModelsFileName(e.target.value)} + disabled={savingMappings} + /> + + {modelSourceFileOptions.map((fileName) => ( + +
@@ -1824,6 +2015,7 @@ export function AuthFilesPage() { updateMappingEntry(index, 'name', e.target.value)} disabled={savingMappings} @@ -1868,6 +2060,13 @@ export function AuthFilesPage() { {t('oauth_model_mappings.add_mapping')}
+ + {mappingModelsList.map((model) => ( + + ))} +
{t('oauth_model_mappings.mappings_hint')}
diff --git a/src/services/api/models.ts b/src/services/api/models.ts index b6d9dd6..fc2299e 100644 --- a/src/services/api/models.ts +++ b/src/services/api/models.ts @@ -20,7 +20,7 @@ const normalizeBaseUrl = (baseUrl: string): string => { const buildModelsEndpoint = (baseUrl: string): string => { const normalized = normalizeBaseUrl(baseUrl); if (!normalized) return ''; - return normalized.endsWith('/v1') ? `${normalized}/models` : `${normalized}/v1/models`; + return `${normalized}/models`; }; export const modelsApi = {