feat: add vertex provider, oauth model mappings, and routing/log settings

This commit is contained in:
Supra4E8C
2026-01-05 19:03:05 +08:00
parent 71556a51c5
commit 8dca670358
18 changed files with 1313 additions and 113 deletions

View File

@@ -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}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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