diff --git a/src/pages/SystemPage.module.scss b/src/pages/SystemPage.module.scss index 7a856f9..604e4cd 100644 --- a/src/pages/SystemPage.module.scss +++ b/src/pages/SystemPage.module.scss @@ -77,6 +77,33 @@ } } +.modelTags { + display: flex; + flex-wrap: wrap; + gap: $spacing-sm; +} + +.modelTag { + display: inline-flex; + align-items: center; + gap: $spacing-xs; + padding: 4px 10px; + border-radius: $radius-full; + border: 1px solid var(--border-color); + background-color: var(--bg-secondary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; +} + +.modelName { + color: var(--text-primary); + font-weight: 600; +} + +.modelAlias { + color: var(--text-secondary); + font-size: 12px; +} + .versionCheck { display: flex; flex-direction: column; diff --git a/src/pages/SystemPage.tsx b/src/pages/SystemPage.tsx index 6458b5c..d06cc4c 100644 --- a/src/pages/SystemPage.tsx +++ b/src/pages/SystemPage.tsx @@ -1,56 +1,136 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import { modelsApi } from '@/services/api/models'; +import { apiKeysApi } from '@/services/api/apiKeys'; import { classifyModels, type ModelInfo } from '@/utils/models'; +import styles from './SystemPage.module.scss'; export function SystemPage() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { showNotification } = useNotificationStore(); const auth = useAuthStore(); - const configStore = useConfigStore(); + const { config, fetchConfig } = useConfigStore((state) => ({ + config: state.config, + fetchConfig: state.fetchConfig + })); const [models, setModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); + const [modelStatus, setModelStatus] = useState<{ type: 'success' | 'warning' | 'error' | 'muted'; message: string }>(); const [error, setError] = useState(''); - const openaiProviders = configStore.config?.openaiCompatibility || []; - const primaryProvider = openaiProviders[0]; - const primaryKey = primaryProvider?.apiKeyEntries?.[0]?.apiKey; + const apiKeysCache = useRef([]); - const groupedModels = useMemo(() => classifyModels(models, { otherLabel: 'Other' }), [models]); + const otherLabel = useMemo( + () => (i18n.language?.toLowerCase().startsWith('zh') ? '其他' : 'Other'), + [i18n.language] + ); + const groupedModels = useMemo(() => classifyModels(models, { otherLabel }), [models, otherLabel]); - const fetchModels = async () => { - if (!primaryProvider?.baseUrl) { - showNotification('No OpenAI provider configured for model fetch', 'warning'); - return; - } - setLoadingModels(true); - setError(''); - try { - const list = await modelsApi.fetchModels(primaryProvider.baseUrl, primaryKey); - setModels(list); - } catch (err: any) { - setError(err?.message || t('notification.refresh_failed')); - } finally { - setLoadingModels(false); - } + const normalizeApiKeyList = (input: any): string[] => { + if (!Array.isArray(input)) return []; + const seen = new Set(); + const keys: string[] = []; + + input.forEach((item) => { + const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? ''; + const trimmed = String(value || '').trim(); + if (!trimmed || seen.has(trimmed)) return; + seen.add(trimmed); + keys.push(trimmed); + }); + + return keys; }; + const resolveApiKeysForModels = useCallback(async () => { + if (apiKeysCache.current.length) { + return apiKeysCache.current; + } + + const configKeys = normalizeApiKeyList(config?.apiKeys); + if (configKeys.length) { + apiKeysCache.current = configKeys; + return configKeys; + } + + try { + const list = await apiKeysApi.list(); + const normalized = normalizeApiKeyList(list); + if (normalized.length) { + apiKeysCache.current = normalized; + } + return normalized; + } catch (err) { + console.warn('Auto loading API keys for models failed:', err); + return []; + } + }, [config?.apiKeys]); + + const fetchModels = useCallback( + async ({ forceRefreshKeys = false }: { forceRefreshKeys?: boolean } = {}) => { + if (auth.connectionStatus !== 'connected') { + setModelStatus({ + type: 'warning', + message: t('notification.connection_required') + }); + setModels([]); + return; + } + + if (!auth.apiBase) { + showNotification(t('notification.connection_required'), 'warning'); + return; + } + + if (forceRefreshKeys) { + apiKeysCache.current = []; + } + + setLoadingModels(true); + setError(''); + setModelStatus({ type: 'muted', message: t('system_info.models_loading') }); + try { + const apiKeys = await resolveApiKeysForModels(); + const primaryKey = apiKeys[0]; + const list = await modelsApi.fetchModels(auth.apiBase, primaryKey); + setModels(list); + const hasModels = list.length > 0; + setModelStatus({ + type: hasModels ? 'success' : 'warning', + message: hasModels ? t('system_info.models_count', { count: list.length }) : t('system_info.models_empty') + }); + } catch (err: any) { + const message = `${t('system_info.models_error')}: ${err?.message || ''}`; + setError(message); + setModels([]); + setModelStatus({ type: 'error', message }); + } finally { + setLoadingModels(false); + } + }, + [auth.apiBase, auth.connectionStatus, resolveApiKeysForModels, showNotification, t] + ); + useEffect(() => { - configStore.fetchConfig().catch(() => { + fetchConfig().catch(() => { // ignore }); - }, []); + }, [fetchConfig]); + + useEffect(() => { + fetchModels(); + }, [fetchModels]); return (
configStore.fetchConfig(undefined, true)}> + } @@ -78,30 +158,39 @@ export function SystemPage() { + } > +

{t('system_info.models_desc')}

+ {modelStatus &&
{modelStatus.message}
} {error &&
{error}
} {loadingModels ? (
{t('common.loading')}
) : models.length === 0 ? ( -
{t('usage_stats.no_data')}
+
{t('system_info.models_empty')}
) : (
{groupedModels.map((group) => (
-
- {group.label} ({group.items.length}) -
-
- {group.items.map((model) => model.name).slice(0, 5).join(', ')} - {group.items.length > 5 ? '…' : ''} -
+
{group.label}
+
{t('system_info.models_count', { count: group.items.length })}
+
+
+ {group.items.map((model) => ( + + {model.name} + {model.alias && {model.alias}} + + ))}
))} diff --git a/src/services/api/models.ts b/src/services/api/models.ts index e9fe055..4e872c4 100644 --- a/src/services/api/models.ts +++ b/src/services/api/models.ts @@ -5,25 +5,37 @@ import axios from 'axios'; import { normalizeModelList } from '@/utils/models'; -const buildModelsEndpoint = (baseUrl: string): string => { - if (!baseUrl) return ''; - const trimmed = String(baseUrl).trim().replace(/\/+$/g, ''); - if (!trimmed) return ''; - if (trimmed.endsWith('/v1')) { - return `${trimmed}/models`; +const normalizeBaseUrl = (baseUrl: string): string => { + let normalized = String(baseUrl || '').trim(); + if (!normalized) return ''; + normalized = normalized.replace(/\/?v0\/management\/?$/i, ''); + normalized = normalized.replace(/\/+$/g, ''); + if (!/^https?:\/\//i.test(normalized)) { + normalized = `http://${normalized}`; } - return `${trimmed}/v1/models`; + return normalized; +}; + +const buildModelsEndpoint = (baseUrl: string): string => { + const normalized = normalizeBaseUrl(baseUrl); + if (!normalized) return ''; + return normalized.endsWith('/v1') ? `${normalized}/models` : `${normalized}/v1/models`; }; export const modelsApi = { - async fetchModels(baseUrl: string, apiKey?: string) { + async fetchModels(baseUrl: string, apiKey?: string, headers: Record = {}) { const endpoint = buildModelsEndpoint(baseUrl); if (!endpoint) { throw new Error('Invalid base url'); } + const resolvedHeaders = { ...headers }; + if (apiKey) { + resolvedHeaders.Authorization = `Bearer ${apiKey}`; + } + const response = await axios.get(endpoint, { - headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined + headers: Object.keys(resolvedHeaders).length ? resolvedHeaders : undefined }); const payload = response.data?.data ?? response.data?.models ?? response.data; return normalizeModelList(payload, { dedupe: true }); diff --git a/src/styles/components.scss b/src/styles/components.scss index 5c9255c..e99caf4 100644 --- a/src/styles/components.scss +++ b/src/styles/components.scss @@ -1,4 +1,5 @@ -@import './variables.scss'; +@use 'sass:color'; +@use './variables.scss' as *; .btn { display: inline-flex; @@ -52,7 +53,7 @@ color: #fff; &:hover { - background-color: darken($error-color, 5%); + background-color: color.adjust($error-color, $lightness: -5%); } } diff --git a/src/styles/global.scss b/src/styles/global.scss index c9729b6..3eb2ce7 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -2,12 +2,12 @@ * 全局样式 */ -@import './variables.scss'; -@import './mixins.scss'; -@import './reset.scss'; -@import './themes.scss'; -@import './components.scss'; -@import './layout.scss'; +@use './variables.scss' as *; +@use './mixins.scss' as *; +@use './reset.scss'; +@use './themes.scss'; +@use './components.scss'; +@use './layout.scss'; body { background-color: var(--bg-secondary); diff --git a/src/styles/layout.scss b/src/styles/layout.scss index 1ca53bc..58161fe 100644 --- a/src/styles/layout.scss +++ b/src/styles/layout.scss @@ -1,4 +1,4 @@ -@import './variables.scss'; +@use './variables.scss' as *; .app-shell { display: flex; diff --git a/src/styles/mixins.scss b/src/styles/mixins.scss index ad1d535..c051696 100644 --- a/src/styles/mixins.scss +++ b/src/styles/mixins.scss @@ -2,6 +2,8 @@ * SCSS 混入 */ +@use './variables.scss' as *; + // 响应式断点 @mixin mobile { @media (max-width: #{$breakpoint-mobile}) { diff --git a/src/styles/reset.scss b/src/styles/reset.scss index ad7bfbe..b8f087c 100644 --- a/src/styles/reset.scss +++ b/src/styles/reset.scss @@ -2,6 +2,8 @@ * CSS Reset */ +@use './variables.scss' as *; + *, *::before, *::after { diff --git a/src/types/style.d.ts b/src/types/style.d.ts new file mode 100644 index 0000000..b201206 --- /dev/null +++ b/src/types/style.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.scss' { + const classes: Record; + export default classes; +} diff --git a/vite.config.ts b/vite.config.ts index ef02a1c..30552e1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ }, preprocessorOptions: { scss: { - additionalData: `@import "@/styles/variables.scss";` + additionalData: `@use "@/styles/variables.scss" as *;` } } },