mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 03:00:49 +08:00
feat: add vertex provider, oauth model mappings, and routing/log settings
This commit is contained in:
@@ -7,11 +7,13 @@ import {
|
||||
CodexSection,
|
||||
GeminiSection,
|
||||
OpenAISection,
|
||||
VertexSection,
|
||||
useProviderStats,
|
||||
type GeminiFormState,
|
||||
type OpenAIFormState,
|
||||
type ProviderFormState,
|
||||
type ProviderModal,
|
||||
type VertexFormState,
|
||||
} from '@/components/providers';
|
||||
import {
|
||||
parseExcludedModels,
|
||||
@@ -41,6 +43,7 @@ export function AiProvidersPage() {
|
||||
const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>([]);
|
||||
const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||
const [vertexConfigs, setVertexConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -63,17 +66,32 @@ export function AiProvidersPage() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await fetchConfig();
|
||||
const [configResult, vertexResult, ampcodeResult] = await Promise.allSettled([
|
||||
fetchConfig(),
|
||||
providersApi.getVertexConfigs(),
|
||||
ampcodeApi.getAmpcode(),
|
||||
]);
|
||||
|
||||
if (configResult.status !== 'fulfilled') {
|
||||
throw configResult.reason;
|
||||
}
|
||||
|
||||
const data = configResult.value;
|
||||
setGeminiKeys(data?.geminiApiKeys || []);
|
||||
setCodexConfigs(data?.codexApiKeys || []);
|
||||
setClaudeConfigs(data?.claudeApiKeys || []);
|
||||
setVertexConfigs(data?.vertexApiKeys || []);
|
||||
setOpenaiProviders(data?.openaiCompatibility || []);
|
||||
try {
|
||||
const ampcode = await ampcodeApi.getAmpcode();
|
||||
updateConfigValue('ampcode', ampcode);
|
||||
|
||||
if (vertexResult.status === 'fulfilled') {
|
||||
setVertexConfigs(vertexResult.value || []);
|
||||
updateConfigValue('vertex-api-key', vertexResult.value || []);
|
||||
clearCache('vertex-api-key');
|
||||
}
|
||||
|
||||
if (ampcodeResult.status === 'fulfilled') {
|
||||
updateConfigValue('ampcode', ampcodeResult.value);
|
||||
clearCache('ampcode');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err) || t('notification.refresh_failed');
|
||||
@@ -92,11 +110,13 @@ export function AiProvidersPage() {
|
||||
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
|
||||
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
|
||||
if (config?.claudeApiKeys) setClaudeConfigs(config.claudeApiKeys);
|
||||
if (config?.vertexApiKeys) setVertexConfigs(config.vertexApiKeys);
|
||||
if (config?.openaiCompatibility) setOpenaiProviders(config.openaiCompatibility);
|
||||
}, [
|
||||
config?.geminiApiKeys,
|
||||
config?.codexApiKeys,
|
||||
config?.claudeApiKeys,
|
||||
config?.vertexApiKeys,
|
||||
config?.openaiCompatibility,
|
||||
]);
|
||||
|
||||
@@ -112,6 +132,10 @@ export function AiProvidersPage() {
|
||||
setModal({ type, index });
|
||||
};
|
||||
|
||||
const openVertexModal = (index: number | null) => {
|
||||
setModal({ type: 'vertex', index });
|
||||
};
|
||||
|
||||
const openAmpcodeModal = () => {
|
||||
setModal({ type: 'ampcode', index: null });
|
||||
};
|
||||
@@ -351,6 +375,72 @@ export function AiProvidersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveVertex = async (form: VertexFormState, editIndex: number | null) => {
|
||||
const trimmedBaseUrl = (form.baseUrl ?? '').trim();
|
||||
const baseUrl = trimmedBaseUrl || undefined;
|
||||
if (!baseUrl) {
|
||||
showNotification(t('notification.vertex_base_url_required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: ProviderKeyConfig = {
|
||||
apiKey: form.apiKey.trim(),
|
||||
prefix: form.prefix?.trim() || undefined,
|
||||
baseUrl,
|
||||
proxyUrl: form.proxyUrl?.trim() || undefined,
|
||||
headers: buildHeaderObject(headersToEntries(form.headers)),
|
||||
models: form.modelEntries
|
||||
.map((entry) => {
|
||||
const name = entry.name.trim();
|
||||
const alias = entry.alias.trim();
|
||||
if (!name || !alias) return null;
|
||||
return { name, alias };
|
||||
})
|
||||
.filter(Boolean) as ProviderKeyConfig['models'],
|
||||
};
|
||||
|
||||
const nextList =
|
||||
editIndex !== null
|
||||
? vertexConfigs.map((item, idx) => (idx === editIndex ? payload : item))
|
||||
: [...vertexConfigs, payload];
|
||||
|
||||
await providersApi.saveVertexConfigs(nextList);
|
||||
setVertexConfigs(nextList);
|
||||
updateConfigValue('vertex-api-key', nextList);
|
||||
clearCache('vertex-api-key');
|
||||
const message =
|
||||
editIndex !== null
|
||||
? t('notification.vertex_config_updated')
|
||||
: t('notification.vertex_config_added');
|
||||
showNotification(message, 'success');
|
||||
closeModal();
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteVertex = async (index: number) => {
|
||||
const entry = vertexConfigs[index];
|
||||
if (!entry) return;
|
||||
if (!window.confirm(t('ai_providers.vertex_delete_confirm'))) return;
|
||||
try {
|
||||
await providersApi.deleteVertexConfig(entry.apiKey);
|
||||
const next = vertexConfigs.filter((_, idx) => idx !== index);
|
||||
setVertexConfigs(next);
|
||||
updateConfigValue('vertex-api-key', next);
|
||||
clearCache('vertex-api-key');
|
||||
showNotification(t('notification.vertex_config_deleted'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -412,6 +502,7 @@ export function AiProvidersPage() {
|
||||
const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null;
|
||||
const codexModalIndex = modal?.type === 'codex' ? modal.index : null;
|
||||
const claudeModalIndex = modal?.type === 'claude' ? modal.index : null;
|
||||
const vertexModalIndex = modal?.type === 'vertex' ? modal.index : null;
|
||||
const openaiModalIndex = modal?.type === 'openai' ? modal.index : null;
|
||||
|
||||
return (
|
||||
@@ -475,6 +566,23 @@ export function AiProvidersPage() {
|
||||
onSave={(form, editIndex) => saveProvider('claude', form, editIndex)}
|
||||
/>
|
||||
|
||||
<VertexSection
|
||||
configs={vertexConfigs}
|
||||
keyStats={keyStats}
|
||||
usageDetails={usageDetails}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSaving={saving}
|
||||
isSwitching={isSwitching}
|
||||
isModalOpen={modal?.type === 'vertex'}
|
||||
modalIndex={vertexModalIndex}
|
||||
onAdd={() => openVertexModal(null)}
|
||||
onEdit={(index) => openVertexModal(index)}
|
||||
onDelete={deleteVertex}
|
||||
onCloseModal={closeModal}
|
||||
onSave={saveVertex}
|
||||
/>
|
||||
|
||||
<AmpcodeSection
|
||||
config={config?.ampcode}
|
||||
loading={loading}
|
||||
|
||||
@@ -751,6 +751,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth 模型映射表单
|
||||
.mappingRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mappingSeparator {
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
|
||||
@include mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mappingFork {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 详情弹窗
|
||||
.detailContent {
|
||||
max-height: 400px;
|
||||
|
||||
@@ -4,15 +4,16 @@ import { useInterval } from '@/hooks/useInterval';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { IconBot, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
|
||||
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconBot, IconDownload, IconInfo, IconTrash2, IconX } from '@/components/ui/icons';
|
||||
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { authFilesApi, usageApi } from '@/services/api';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import type { AuthFileItem, OAuthModelMappingEntry } from '@/types';
|
||||
import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
|
||||
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
|
||||
import { formatFileSize } from '@/utils/format';
|
||||
@@ -90,6 +91,17 @@ interface ExcludedFormState {
|
||||
provider: string;
|
||||
modelsText: string;
|
||||
}
|
||||
|
||||
interface ModelMappingsFormState {
|
||||
provider: string;
|
||||
mappings: OAuthModelMappingEntry[];
|
||||
}
|
||||
|
||||
const buildEmptyMappingEntry = (): OAuthModelMappingEntry => ({
|
||||
name: '',
|
||||
alias: '',
|
||||
fork: false
|
||||
});
|
||||
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
|
||||
function normalizeAuthIndexValue(value: unknown): string | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -181,14 +193,25 @@ export function AuthFilesPage() {
|
||||
|
||||
// OAuth 排除模型相关
|
||||
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
||||
const [excludedError, setExcludedError] = useState<'unsupported' | null>(null);
|
||||
const [excludedError, setExcludedError] = useState<'unsupported' | null>(null);
|
||||
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
|
||||
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' });
|
||||
const [savingExcluded, setSavingExcluded] = useState(false);
|
||||
|
||||
// OAuth 模型映射相关
|
||||
const [modelMappings, setModelMappings] = useState<Record<string, OAuthModelMappingEntry[]>>({});
|
||||
const [modelMappingsError, setModelMappingsError] = useState<'unsupported' | null>(null);
|
||||
const [mappingModalOpen, setMappingModalOpen] = useState(false);
|
||||
const [mappingForm, setMappingForm] = useState<ModelMappingsFormState>({
|
||||
provider: '',
|
||||
mappings: [buildEmptyMappingEntry()]
|
||||
});
|
||||
const [savingMappings, setSavingMappings] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const loadingKeyStatsRef = useRef(false);
|
||||
const excludedUnsupportedRef = useRef(false);
|
||||
const mappingsUnsupportedRef = useRef(false);
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
@@ -252,7 +275,7 @@ export function AuthFilesPage() {
|
||||
const res = await authFilesApi.getOauthExcludedModels();
|
||||
excludedUnsupportedRef.current = false;
|
||||
setExcluded(res || {});
|
||||
setExcludedError(null);
|
||||
setExcludedError(null);
|
||||
} catch (err: unknown) {
|
||||
const status =
|
||||
typeof err === 'object' && err !== null && 'status' in err
|
||||
@@ -272,9 +295,35 @@ export function AuthFilesPage() {
|
||||
}
|
||||
}, [showNotification, t]);
|
||||
|
||||
// 加载 OAuth 模型映射
|
||||
const loadModelMappings = useCallback(async () => {
|
||||
try {
|
||||
const res = await authFilesApi.getOauthModelMappings();
|
||||
mappingsUnsupportedRef.current = false;
|
||||
setModelMappings(res || {});
|
||||
setModelMappingsError(null);
|
||||
} catch (err: unknown) {
|
||||
const status =
|
||||
typeof err === 'object' && err !== null && 'status' in err
|
||||
? (err as { status?: unknown }).status
|
||||
: undefined;
|
||||
|
||||
if (status === 404) {
|
||||
setModelMappings({});
|
||||
setModelMappingsError('unsupported');
|
||||
if (!mappingsUnsupportedRef.current) {
|
||||
mappingsUnsupportedRef.current = true;
|
||||
showNotification(t('oauth_model_mappings.upgrade_required'), 'warning');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 静默失败
|
||||
}
|
||||
}, [showNotification, t]);
|
||||
|
||||
const handleHeaderRefresh = useCallback(async () => {
|
||||
await Promise.all([loadFiles(), loadKeyStats(), loadExcluded()]);
|
||||
}, [loadFiles, loadKeyStats, loadExcluded]);
|
||||
await Promise.all([loadFiles(), loadKeyStats(), loadExcluded(), loadModelMappings()]);
|
||||
}, [loadFiles, loadKeyStats, loadExcluded, loadModelMappings]);
|
||||
|
||||
useHeaderRefresh(handleHeaderRefresh);
|
||||
|
||||
@@ -282,7 +331,8 @@ export function AuthFilesPage() {
|
||||
loadFiles();
|
||||
loadKeyStats();
|
||||
loadExcluded();
|
||||
}, [loadFiles, loadKeyStats, loadExcluded]);
|
||||
loadModelMappings();
|
||||
}, [loadFiles, loadKeyStats, loadExcluded, loadModelMappings]);
|
||||
|
||||
// 定时刷新状态数据(每240秒)
|
||||
useInterval(loadKeyStats, 240_000);
|
||||
@@ -310,12 +360,26 @@ export function AuthFilesPage() {
|
||||
return lookup;
|
||||
}, [excluded]);
|
||||
|
||||
const mappingProviderLookup = useMemo(() => {
|
||||
const lookup = new Map<string, string>();
|
||||
Object.keys(modelMappings).forEach((provider) => {
|
||||
const key = provider.trim().toLowerCase();
|
||||
if (key && !lookup.has(key)) {
|
||||
lookup.set(key, provider);
|
||||
}
|
||||
});
|
||||
return lookup;
|
||||
}, [modelMappings]);
|
||||
|
||||
const providerOptions = useMemo(() => {
|
||||
const extraProviders = new Set<string>();
|
||||
|
||||
Object.keys(excluded).forEach((provider) => {
|
||||
extraProviders.add(provider);
|
||||
});
|
||||
Object.keys(modelMappings).forEach((provider) => {
|
||||
extraProviders.add(provider);
|
||||
});
|
||||
files.forEach((file) => {
|
||||
if (typeof file.type === 'string') {
|
||||
extraProviders.add(file.type);
|
||||
@@ -335,7 +399,7 @@ export function AuthFilesPage() {
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
|
||||
}, [excluded, files]);
|
||||
}, [excluded, files, modelMappings]);
|
||||
|
||||
// 过滤和搜索
|
||||
const filtered = useMemo(() => {
|
||||
@@ -604,12 +668,12 @@ export function AuthFilesPage() {
|
||||
setExcludedModalOpen(true);
|
||||
};
|
||||
|
||||
const saveExcludedModels = async () => {
|
||||
const provider = excludedForm.provider.trim();
|
||||
if (!provider) {
|
||||
showNotification(t('oauth_excluded.provider_required'), 'error');
|
||||
return;
|
||||
}
|
||||
const saveExcludedModels = async () => {
|
||||
const provider = excludedForm.provider.trim();
|
||||
if (!provider) {
|
||||
showNotification(t('oauth_excluded.provider_required'), 'error');
|
||||
return;
|
||||
}
|
||||
const models = excludedForm.modelsText
|
||||
.split(/[\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
@@ -628,11 +692,11 @@ export function AuthFilesPage() {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('oauth_excluded.save_failed')}: ${errorMessage}`, 'error');
|
||||
} finally {
|
||||
setSavingExcluded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteExcluded = async (provider: string) => {
|
||||
setSavingExcluded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteExcluded = async (provider: string) => {
|
||||
if (!window.confirm(t('oauth_excluded.delete_confirm', { provider }))) return;
|
||||
try {
|
||||
await authFilesApi.deleteOauthExcludedEntry(provider);
|
||||
@@ -641,8 +705,110 @@ export function AuthFilesPage() {
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// OAuth 模型映射相关方法
|
||||
const normalizeMappingEntries = (entries?: OAuthModelMappingEntry[]) => {
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
return [buildEmptyMappingEntry()];
|
||||
}
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name ?? '',
|
||||
alias: entry.alias ?? '',
|
||||
fork: Boolean(entry.fork),
|
||||
}));
|
||||
};
|
||||
|
||||
const openMappingsModal = (provider?: string) => {
|
||||
const normalizedProvider = (provider || '').trim();
|
||||
const fallbackProvider = normalizedProvider || (filter !== 'all' ? String(filter) : '');
|
||||
const lookupKey = fallbackProvider
|
||||
? mappingProviderLookup.get(fallbackProvider.toLowerCase())
|
||||
: undefined;
|
||||
const mappings = lookupKey ? modelMappings[lookupKey] : [];
|
||||
setMappingForm({
|
||||
provider: lookupKey || fallbackProvider,
|
||||
mappings: normalizeMappingEntries(mappings),
|
||||
});
|
||||
setMappingModalOpen(true);
|
||||
};
|
||||
|
||||
const updateMappingEntry = (index: number, field: keyof OAuthModelMappingEntry, value: string | boolean) => {
|
||||
setMappingForm((prev) => ({
|
||||
...prev,
|
||||
mappings: prev.mappings.map((entry, idx) =>
|
||||
idx === index ? { ...entry, [field]: value } : entry
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addMappingEntry = () => {
|
||||
setMappingForm((prev) => ({
|
||||
...prev,
|
||||
mappings: [...prev.mappings, buildEmptyMappingEntry()],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeMappingEntry = (index: number) => {
|
||||
setMappingForm((prev) => {
|
||||
const next = prev.mappings.filter((_, idx) => idx !== index);
|
||||
return {
|
||||
...prev,
|
||||
mappings: next.length ? next : [buildEmptyMappingEntry()],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const saveModelMappings = async () => {
|
||||
const provider = mappingForm.provider.trim();
|
||||
if (!provider) {
|
||||
showNotification(t('oauth_model_mappings.provider_required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const mappings = mappingForm.mappings
|
||||
.map((entry) => {
|
||||
const name = String(entry.name ?? '').trim();
|
||||
const alias = String(entry.alias ?? '').trim();
|
||||
if (!name || !alias) return null;
|
||||
const key = `${name.toLowerCase()}::${alias.toLowerCase()}::${entry.fork ? '1' : '0'}`;
|
||||
if (seen.has(key)) return null;
|
||||
seen.add(key);
|
||||
return entry.fork ? { name, alias, fork: true } : { name, alias };
|
||||
})
|
||||
.filter(Boolean) as OAuthModelMappingEntry[];
|
||||
|
||||
setSavingMappings(true);
|
||||
try {
|
||||
if (mappings.length) {
|
||||
await authFilesApi.saveOauthModelMappings(provider, mappings);
|
||||
} else {
|
||||
await authFilesApi.deleteOauthModelMappings(provider);
|
||||
}
|
||||
await loadModelMappings();
|
||||
showNotification(t('oauth_model_mappings.save_success'), 'success');
|
||||
setMappingModalOpen(false);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('oauth_model_mappings.save_failed')}: ${errorMessage}`, 'error');
|
||||
} finally {
|
||||
setSavingMappings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteModelMappings = async (provider: string) => {
|
||||
if (!window.confirm(t('oauth_model_mappings.delete_confirm', { provider }))) return;
|
||||
try {
|
||||
await authFilesApi.deleteOauthModelMappings(provider);
|
||||
await loadModelMappings();
|
||||
showNotification(t('oauth_model_mappings.delete_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('oauth_model_mappings.delete_failed')}: ${errorMessage}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染标签筛选器
|
||||
const renderFilterTags = () => (
|
||||
@@ -959,14 +1125,14 @@ export function AuthFilesPage() {
|
||||
title={t('oauth_excluded.title')}
|
||||
extra={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => openExcludedModal()}
|
||||
disabled={disableControls || excludedError === 'unsupported'}
|
||||
>
|
||||
{t('oauth_excluded.add')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
size="sm"
|
||||
onClick={() => openExcludedModal()}
|
||||
disabled={disableControls || excludedError === 'unsupported'}
|
||||
>
|
||||
{t('oauth_excluded.add')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{excludedError === 'unsupported' ? (
|
||||
<EmptyState
|
||||
title={t('oauth_excluded.upgrade_required_title')}
|
||||
@@ -997,12 +1163,58 @@ export function AuthFilesPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
open={detailModalOpen}
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* OAuth 模型映射卡片 */}
|
||||
<Card
|
||||
title={t('oauth_model_mappings.title')}
|
||||
extra={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => openMappingsModal()}
|
||||
disabled={disableControls || modelMappingsError === 'unsupported'}
|
||||
>
|
||||
{t('oauth_model_mappings.add')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{modelMappingsError === 'unsupported' ? (
|
||||
<EmptyState
|
||||
title={t('oauth_model_mappings.upgrade_required_title')}
|
||||
description={t('oauth_model_mappings.upgrade_required_desc')}
|
||||
/>
|
||||
) : Object.keys(modelMappings).length === 0 ? (
|
||||
<EmptyState title={t('oauth_model_mappings.list_empty_all')} />
|
||||
) : (
|
||||
<div className={styles.excludedList}>
|
||||
{Object.entries(modelMappings).map(([provider, mappings]) => (
|
||||
<div key={provider} className={styles.excludedItem}>
|
||||
<div className={styles.excludedInfo}>
|
||||
<div className={styles.excludedProvider}>{provider}</div>
|
||||
<div className={styles.excludedModels}>
|
||||
{mappings?.length
|
||||
? t('oauth_model_mappings.model_count', { count: mappings.length })
|
||||
: t('oauth_model_mappings.no_models')}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.excludedActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => openMappingsModal(provider)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => deleteModelMappings(provider)}>
|
||||
{t('oauth_model_mappings.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
open={detailModalOpen}
|
||||
onClose={() => setDetailModalOpen(false)}
|
||||
title={selectedFile?.name || t('auth_files.title_section')}
|
||||
footer={
|
||||
@@ -1146,9 +1358,117 @@ export function AuthFilesPage() {
|
||||
value={excludedForm.modelsText}
|
||||
onChange={(e) => setExcludedForm((prev) => ({ ...prev, modelsText: e.target.value }))}
|
||||
/>
|
||||
<div className={styles.hint}>{t('oauth_excluded.models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div className={styles.hint}>{t('oauth_excluded.models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* OAuth 模型映射弹窗 */}
|
||||
<Modal
|
||||
open={mappingModalOpen}
|
||||
onClose={() => setMappingModalOpen(false)}
|
||||
title={t('oauth_model_mappings.add_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setMappingModalOpen(false)} disabled={savingMappings}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={saveModelMappings} loading={savingMappings}>
|
||||
{t('oauth_model_mappings.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.providerField}>
|
||||
<Input
|
||||
id="oauth-model-mappings-provider"
|
||||
list="oauth-model-mappings-provider-options"
|
||||
label={t('oauth_model_mappings.provider_label')}
|
||||
hint={t('oauth_model_mappings.provider_hint')}
|
||||
placeholder={t('oauth_model_mappings.provider_placeholder')}
|
||||
value={mappingForm.provider}
|
||||
onChange={(e) => setMappingForm((prev) => ({ ...prev, provider: e.target.value }))}
|
||||
/>
|
||||
<datalist id="oauth-model-mappings-provider-options">
|
||||
{providerOptions.map((provider) => (
|
||||
<option key={provider} value={provider} />
|
||||
))}
|
||||
</datalist>
|
||||
{providerOptions.length > 0 && (
|
||||
<div className={styles.providerTagList}>
|
||||
{providerOptions.map((provider) => {
|
||||
const isActive =
|
||||
mappingForm.provider.trim().toLowerCase() === provider.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
className={`${styles.providerTag} ${isActive ? styles.providerTagActive : ''}`}
|
||||
onClick={() => setMappingForm((prev) => ({ ...prev, provider }))}
|
||||
disabled={savingMappings}
|
||||
>
|
||||
{getTypeLabel(provider)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>{t('oauth_model_mappings.mappings_label')}</label>
|
||||
<div className="header-input-list">
|
||||
{(mappingForm.mappings.length ? mappingForm.mappings : [buildEmptyMappingEntry()]).map(
|
||||
(entry, index) => (
|
||||
<div key={`${entry.name}-${entry.alias}-${index}`} className={styles.mappingRow}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('oauth_model_mappings.mapping_name_placeholder')}
|
||||
value={entry.name}
|
||||
onChange={(e) => updateMappingEntry(index, 'name', e.target.value)}
|
||||
disabled={savingMappings}
|
||||
/>
|
||||
<span className={styles.mappingSeparator}>→</span>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('oauth_model_mappings.mapping_alias_placeholder')}
|
||||
value={entry.alias}
|
||||
onChange={(e) => updateMappingEntry(index, 'alias', e.target.value)}
|
||||
disabled={savingMappings}
|
||||
/>
|
||||
<div className={styles.mappingFork}>
|
||||
<ToggleSwitch
|
||||
label={t('oauth_model_mappings.mapping_fork_label')}
|
||||
labelPosition="left"
|
||||
checked={Boolean(entry.fork)}
|
||||
onChange={(value) => updateMappingEntry(index, 'fork', value)}
|
||||
disabled={savingMappings}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeMappingEntry(index)}
|
||||
disabled={savingMappings || mappingForm.mappings.length <= 1}
|
||||
title={t('common.delete')}
|
||||
aria-label={t('common.delete')}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={addMappingEntry}
|
||||
disabled={savingMappings}
|
||||
className="align-start"
|
||||
>
|
||||
{t('oauth_model_mappings.add_mapping')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.hint}>{t('oauth_model_mappings.mappings_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ type PendingKey =
|
||||
| 'debug'
|
||||
| 'proxy'
|
||||
| 'retry'
|
||||
| 'logsMaxSize'
|
||||
| 'forceModelPrefix'
|
||||
| 'routingStrategy'
|
||||
| 'switchProject'
|
||||
| 'switchPreview'
|
||||
| 'usage'
|
||||
@@ -31,6 +34,8 @@ export function SettingsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [proxyValue, setProxyValue] = useState('');
|
||||
const [retryValue, setRetryValue] = useState(0);
|
||||
const [logsMaxTotalSizeMb, setLogsMaxTotalSizeMb] = useState(0);
|
||||
const [routingStrategy, setRoutingStrategy] = useState('round-robin');
|
||||
const [pending, setPending] = useState<Record<PendingKey, boolean>>({} as Record<PendingKey, boolean>);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -41,9 +46,34 @@ export function SettingsPage() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = (await fetchConfig()) as Config;
|
||||
const [configResult, logsResult, prefixResult, routingResult] = await Promise.allSettled([
|
||||
fetchConfig(),
|
||||
configApi.getLogsMaxTotalSizeMb(),
|
||||
configApi.getForceModelPrefix(),
|
||||
configApi.getRoutingStrategy(),
|
||||
]);
|
||||
|
||||
if (configResult.status !== 'fulfilled') {
|
||||
throw configResult.reason;
|
||||
}
|
||||
|
||||
const data = configResult.value as Config;
|
||||
setProxyValue(data?.proxyUrl ?? '');
|
||||
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
|
||||
|
||||
if (logsResult.status === 'fulfilled' && Number.isFinite(logsResult.value)) {
|
||||
setLogsMaxTotalSizeMb(Math.max(0, Number(logsResult.value)));
|
||||
updateConfigValue('logs-max-total-size-mb', Math.max(0, Number(logsResult.value)));
|
||||
}
|
||||
|
||||
if (prefixResult.status === 'fulfilled') {
|
||||
updateConfigValue('force-model-prefix', Boolean(prefixResult.value));
|
||||
}
|
||||
|
||||
if (routingResult.status === 'fulfilled' && routingResult.value) {
|
||||
setRoutingStrategy(String(routingResult.value));
|
||||
updateConfigValue('routing/strategy', String(routingResult.value));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
@@ -52,7 +82,7 @@ export function SettingsPage() {
|
||||
};
|
||||
|
||||
load();
|
||||
}, [fetchConfig, t]);
|
||||
}, [fetchConfig, t, updateConfigValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
@@ -60,8 +90,14 @@ export function SettingsPage() {
|
||||
if (typeof config.requestRetry === 'number') {
|
||||
setRetryValue(config.requestRetry);
|
||||
}
|
||||
if (typeof config.logsMaxTotalSizeMb === 'number') {
|
||||
setLogsMaxTotalSizeMb(config.logsMaxTotalSizeMb);
|
||||
}
|
||||
if (config.routingStrategy) {
|
||||
setRoutingStrategy(config.routingStrategy);
|
||||
}
|
||||
}
|
||||
}, [config?.proxyUrl, config?.requestRetry]);
|
||||
}, [config?.proxyUrl, config?.requestRetry, config?.logsMaxTotalSizeMb, config?.routingStrategy]);
|
||||
|
||||
const setPendingFlag = (key: PendingKey, value: boolean) => {
|
||||
setPending((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -69,7 +105,7 @@ export function SettingsPage() {
|
||||
|
||||
const toggleSetting = async (
|
||||
section: PendingKey,
|
||||
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth',
|
||||
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth' | 'force-model-prefix',
|
||||
value: boolean,
|
||||
updater: (val: boolean) => Promise<any>,
|
||||
successMessage: string
|
||||
@@ -84,6 +120,8 @@ export function SettingsPage() {
|
||||
return config?.loggingToFile ?? false;
|
||||
case 'ws-auth':
|
||||
return config?.wsAuth ?? false;
|
||||
case 'force-model-prefix':
|
||||
return config?.forceModelPrefix ?? false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -162,6 +200,52 @@ export function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogsMaxTotalSizeUpdate = async () => {
|
||||
const previous = config?.logsMaxTotalSizeMb ?? 0;
|
||||
const parsed = Number(logsMaxTotalSizeMb);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
showNotification(t('login.error_invalid'), 'error');
|
||||
setLogsMaxTotalSizeMb(previous);
|
||||
return;
|
||||
}
|
||||
const normalized = Math.max(0, parsed);
|
||||
setPendingFlag('logsMaxSize', true);
|
||||
updateConfigValue('logs-max-total-size-mb', normalized);
|
||||
try {
|
||||
await configApi.updateLogsMaxTotalSizeMb(normalized);
|
||||
clearCache('logs-max-total-size-mb');
|
||||
showNotification(t('notification.logs_max_total_size_updated'), 'success');
|
||||
} catch (err: any) {
|
||||
setLogsMaxTotalSizeMb(previous);
|
||||
updateConfigValue('logs-max-total-size-mb', previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('logsMaxSize', false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoutingStrategyUpdate = async () => {
|
||||
const strategy = routingStrategy.trim();
|
||||
if (!strategy) {
|
||||
showNotification(t('login.error_invalid'), 'error');
|
||||
return;
|
||||
}
|
||||
const previous = config?.routingStrategy ?? 'round-robin';
|
||||
setPendingFlag('routingStrategy', true);
|
||||
updateConfigValue('routing/strategy', strategy);
|
||||
try {
|
||||
await configApi.updateRoutingStrategy(strategy);
|
||||
clearCache('routing/strategy');
|
||||
showNotification(t('notification.routing_strategy_updated'), 'success');
|
||||
} catch (err: any) {
|
||||
setRoutingStrategy(previous);
|
||||
updateConfigValue('routing/strategy', previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('routingStrategy', false);
|
||||
}
|
||||
};
|
||||
|
||||
const quotaSwitchProject = config?.quotaExceeded?.switchProject ?? false;
|
||||
const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false;
|
||||
|
||||
@@ -171,63 +255,78 @@ export function SettingsPage() {
|
||||
|
||||
<div className={styles.grid}>
|
||||
<Card>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.debug_enable')}
|
||||
checked={config?.debug ?? false}
|
||||
disabled={disableControls || pending.debug || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated'))
|
||||
}
|
||||
/>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.debug_enable')}
|
||||
checked={config?.debug ?? false}
|
||||
disabled={disableControls || pending.debug || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated'))
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.usage_statistics_enable')}
|
||||
checked={config?.usageStatisticsEnabled ?? false}
|
||||
disabled={disableControls || pending.usage || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'usage',
|
||||
'usage-statistics-enabled',
|
||||
value,
|
||||
configApi.updateUsageStatistics,
|
||||
t('notification.usage_statistics_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.usage_statistics_enable')}
|
||||
checked={config?.usageStatisticsEnabled ?? false}
|
||||
disabled={disableControls || pending.usage || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'usage',
|
||||
'usage-statistics-enabled',
|
||||
value,
|
||||
configApi.updateUsageStatistics,
|
||||
t('notification.usage_statistics_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.logging_to_file_enable')}
|
||||
checked={config?.loggingToFile ?? false}
|
||||
disabled={disableControls || pending.loggingToFile || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'loggingToFile',
|
||||
'logging-to-file',
|
||||
value,
|
||||
configApi.updateLoggingToFile,
|
||||
t('notification.logging_to_file_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.logging_to_file_enable')}
|
||||
checked={config?.loggingToFile ?? false}
|
||||
disabled={disableControls || pending.loggingToFile || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'loggingToFile',
|
||||
'logging-to-file',
|
||||
value,
|
||||
configApi.updateLoggingToFile,
|
||||
t('notification.logging_to_file_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.ws_auth_enable')}
|
||||
checked={config?.wsAuth ?? false}
|
||||
disabled={disableControls || pending.wsAuth || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'wsAuth',
|
||||
'ws-auth',
|
||||
value,
|
||||
configApi.updateWsAuth,
|
||||
t('notification.ws_auth_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.ws_auth_enable')}
|
||||
checked={config?.wsAuth ?? false}
|
||||
disabled={disableControls || pending.wsAuth || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'wsAuth',
|
||||
'ws-auth',
|
||||
value,
|
||||
configApi.updateWsAuth,
|
||||
t('notification.ws_auth_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.force_model_prefix_enable')}
|
||||
checked={config?.forceModelPrefix ?? false}
|
||||
disabled={disableControls || pending.forceModelPrefix || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'forceModelPrefix',
|
||||
'force-model-prefix',
|
||||
value,
|
||||
configApi.updateForceModelPrefix,
|
||||
t('notification.force_model_prefix_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('basic_settings.proxy_title')}>
|
||||
<Input
|
||||
@@ -271,6 +370,57 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('basic_settings.logs_max_total_size_title')}>
|
||||
<div className={styles.retryRow}>
|
||||
<Input
|
||||
label={t('basic_settings.logs_max_total_size_label')}
|
||||
hint={t('basic_settings.logs_max_total_size_hint')}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
step={1}
|
||||
value={logsMaxTotalSizeMb}
|
||||
onChange={(e) => setLogsMaxTotalSizeMb(Number(e.target.value))}
|
||||
disabled={disableControls || loading}
|
||||
className={styles.retryInput}
|
||||
/>
|
||||
<Button
|
||||
className={styles.retryButton}
|
||||
onClick={handleLogsMaxTotalSizeUpdate}
|
||||
loading={pending.logsMaxSize}
|
||||
disabled={disableControls || loading}
|
||||
>
|
||||
{t('basic_settings.logs_max_total_size_update')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('basic_settings.routing_title')}>
|
||||
<div className={styles.retryRow}>
|
||||
<div className="form-group">
|
||||
<label>{t('basic_settings.routing_strategy_label')}</label>
|
||||
<select
|
||||
className="input"
|
||||
value={routingStrategy}
|
||||
onChange={(e) => setRoutingStrategy(e.target.value)}
|
||||
disabled={disableControls || loading}
|
||||
>
|
||||
<option value="round-robin">{t('basic_settings.routing_strategy_round_robin')}</option>
|
||||
<option value="fill-first">{t('basic_settings.routing_strategy_fill_first')}</option>
|
||||
</select>
|
||||
<div className="hint">{t('basic_settings.routing_strategy_hint')}</div>
|
||||
</div>
|
||||
<Button
|
||||
className={styles.retryButton}
|
||||
onClick={handleRoutingStrategyUpdate}
|
||||
loading={pending.routingStrategy}
|
||||
disabled={disableControls || loading}
|
||||
>
|
||||
{t('basic_settings.routing_strategy_update')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('basic_settings.quota_title')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<ToggleSwitch
|
||||
|
||||
Reference in New Issue
Block a user