Compare commits

...

5 Commits

27 changed files with 861 additions and 388 deletions
+61
View File
@@ -10,6 +10,7 @@ import {
} from '@/utils/recentRequests';
const DISABLE_ALL_MODELS_RULE = '*';
const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com';
export const hasDisableAllModelsRule = (models?: string[]) =>
Array.isArray(models) &&
@@ -52,6 +53,33 @@ const normalizeClaudeBaseUrl = (baseUrl: string): string => {
return trimmed;
};
const normalizeGeminiBaseUrl = (baseUrl: string): string => {
let trimmed = String(baseUrl || '').trim();
if (!trimmed) {
return DEFAULT_GEMINI_BASE_URL;
}
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
trimmed = trimmed.replace(/\/+$/g, '');
if (!/^https?:\/\//i.test(trimmed)) {
trimmed = `http://${trimmed}`;
}
return trimmed;
};
const buildGeminiModelResource = (model: string): string => {
const trimmed = String(model || '')
.trim()
.replace(/^\/+/g, '')
.replace(/:generateContent$/i, '');
if (!trimmed) return '';
if (/^(models|tunedModels)\//i.test(trimmed)) {
return trimmed.split('/').map(encodeURIComponent).join('/');
}
return `models/${encodeURIComponent(trimmed)}`;
};
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
if (!trimmed) return '';
@@ -73,6 +101,30 @@ export const buildClaudeMessagesEndpoint = (baseUrl: string): string => {
return `${trimmed}/v1/messages`;
};
export const buildGeminiGenerateContentEndpoint = (
baseUrl: string,
model: string
): string => {
const resource = buildGeminiModelResource(model);
if (!resource) return '';
const trimmed = normalizeGeminiBaseUrl(baseUrl);
if (!trimmed) return '';
if (/:generateContent$/i.test(trimmed)) {
return trimmed;
}
let root = trimmed.replace(/\/+$/g, '');
if (/\/v1beta\/models$/i.test(root)) {
root = root.replace(/\/models$/i, '');
} else if (!/\/v1beta$/i.test(root)) {
root = root.replace(/\/v1beta(?:\/.*)?$/i, '');
root = `${root}/v1beta`;
}
return `${root}/${resource}:generateContent`;
};
export type ProviderRecentUsageMap = Map<string, Map<string, RecentRequestUsageEntry>>;
const EMPTY_RECENT_USAGE_ENTRY: RecentRequestUsageEntry = {
@@ -128,6 +180,15 @@ export function getProviderTotalStats(
return { success: entry.success, failure: entry.failed };
}
export function getProviderRecentWindowStats(
usageByProvider: ProviderRecentUsageMap,
provider: string,
apiKey?: string,
baseUrl?: string
): { success: number; failure: number } {
return sumRecentRequests(getProviderRecentBuckets(usageByProvider, provider, apiKey, baseUrl));
}
const collectOpenAIProviderRecentBuckets = (
provider: OpenAIProviderConfig,
usageByProvider: ProviderRecentUsageMap
+22
View File
@@ -182,6 +182,28 @@ export function IconTrash2({ size = 20, ...props }: IconProps) {
);
}
export function IconMaximize2({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M15 3h6v6" />
<path d="m21 3-7 7" />
<path d="M9 21H3v-6" />
<path d="m3 21 7-7" />
</svg>
);
}
export function IconMinimize2({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M4 14h6v6" />
<path d="m10 14-7 7" />
<path d="M20 10h-6V4" />
<path d="m14 10 7-7" />
</svg>
);
}
export function IconPlus({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
+31 -4
View File
@@ -11,6 +11,7 @@ import iconKimiLight from '@/assets/icons/kimi-light.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconVertex from '@/assets/icons/vertex.svg';
import type { AuthFileItem } from '@/types';
import { normalizeOAuthProviderKey } from '@/utils/providerKeys';
import { parseTimestamp } from '@/utils/timestamp';
export type ThemeColors = { bg: string; text: string; border?: string };
@@ -35,6 +36,19 @@ export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>([
'xai',
]);
export const OAUTH_PROVIDER_PRESETS = [
'gemini-cli',
'vertex',
'aistudio',
'antigravity',
'xai',
'claude',
'codex',
'kimi',
];
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
export const MIN_CARD_PAGE_SIZE = 3;
export const MAX_CARD_PAGE_SIZE = 30;
export const AUTH_FILE_REFRESH_WARNING_MS = 24 * 60 * 60 * 1000;
@@ -137,10 +151,23 @@ export const resolveQuotaErrorMessage = (
return fallback;
};
export const normalizeProviderKey = (value: string) => {
const key = value.trim().toLowerCase().replace(/_/g, '-');
if (key === 'x-ai' || key === 'grok') return 'xai';
return key;
export const normalizeProviderKey = normalizeOAuthProviderKey;
export const buildOAuthProviderOptions = (values: Iterable<unknown>): string[] => {
const extraProviders = new Set<string>();
Array.from(values).forEach((value) => {
const key = normalizeProviderKey(String(value ?? ''));
if (!key || OAUTH_PROVIDER_EXCLUDES.has(key)) return;
extraProviders.add(key);
});
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => normalizeProviderKey(value)));
const extraList = Array.from(extraProviders)
.filter((value) => !baseSet.has(value))
.sort((a, b) => a.localeCompare(b));
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
};
export const getAuthFileStatusMessage = (file: AuthFileItem): string => {
+106 -63
View File
@@ -5,16 +5,20 @@ import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { Skeleton } from '@/components/ui/Skeleton';
import { useAuthStore, useNotificationStore } from '@/stores';
import { useProviderRecentRequests } from '@/components/providers/hooks/useProviderRecentRequests';
import { getOpenAIProviderRecentWindowStats } from '@/components/providers/utils';
import {
getOpenAIProviderRecentWindowStats,
getProviderRecentWindowStats,
type ProviderRecentUsageMap,
} from '@/components/providers/utils';
import type { OpenAIProviderConfig } from '@/types';
import { ProviderHeaderCard } from './components/ProviderHeaderCard';
import { ProviderCategoryList } from './components/ProviderCategoryList';
import { ProviderResourcePanel } from './components/ProviderResourcePanel';
import type { OpenAIPanelControls } from './components/ProviderResourcePanel';
import type { ProviderPanelControls } from './components/ProviderResourcePanel';
import type {
OpenAISortBy,
ProviderSortBy,
SortDir,
} from './components/OpenAIBrandToolbar';
} from './components/ProviderResourceToolbar';
import { ProviderSheet, type ProviderSheetHandle } from './sheets/ProviderSheet';
import { useProviderWorkbench } from './useProviderWorkbench';
import type { ProviderBrand, ProviderResource } from './types';
@@ -59,6 +63,63 @@ const matchesFilter = (r: ProviderResource, normalized: string): boolean => {
return haystack.some((v) => v.includes(normalized));
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
const getResourceModels = (resource: ProviderResource): string[] => {
if (!isRecord(resource.raw)) return [];
if (resource.brand === 'ampcode') {
const mappings = resource.raw.modelMappings;
if (!Array.isArray(mappings)) return [];
const seen = new Set<string>();
mappings.forEach((mapping) => {
if (!isRecord(mapping)) return;
const from = typeof mapping.from === 'string' ? mapping.from.trim() : '';
const to = typeof mapping.to === 'string' ? mapping.to.trim() : '';
if (from) seen.add(from);
if (to) seen.add(to);
});
return Array.from(seen);
}
const models = resource.raw.models;
if (!Array.isArray(models)) return [];
const seen = new Set<string>();
models.forEach((model) => {
if (!isRecord(model)) return;
const name = typeof model.name === 'string' ? model.name.trim() : '';
if (name) seen.add(name);
});
return Array.from(seen);
};
const getResourcePriority = (resource: ProviderResource): number => {
if (!isRecord(resource.raw)) return 0;
const priority = resource.raw.priority;
return typeof priority === 'number' && Number.isFinite(priority) ? priority : 0;
};
const getResourceSortName = (resource: ProviderResource): string =>
(resource.name ?? resource.identifier ?? resource.apiKeyPreview ?? '').toLowerCase();
const getResourceRecentSuccess = (
resource: ProviderResource,
usageByProvider: ProviderRecentUsageMap
): number => {
if (resource.brand === 'openaiCompatibility') {
return getOpenAIProviderRecentWindowStats(
resource.raw as OpenAIProviderConfig,
usageByProvider
).success;
}
if (resource.brand === 'ampcode') return 0;
return getProviderRecentWindowStats(
usageByProvider,
resource.brand,
resource.apiKey ?? undefined,
resource.baseUrl ?? undefined
).success;
};
export function ProvidersWorkbenchPage() {
const { t, i18n } = useTranslation();
const connectionStatus = useAuthStore((s) => s.connectionStatus);
@@ -70,11 +131,9 @@ export function ProvidersWorkbenchPage() {
const workbench = useProviderWorkbench();
const [activeBrand, setActiveBrand] = useState<ProviderBrand>('gemini');
const [filter, setFilter] = useState('');
const [openaiSortBy, setOpenaiSortBy] = useState<OpenAISortBy>('name');
const [openaiSortDir, setOpenaiSortDir] = useState<SortDir>('asc');
const [openaiSelectedModels, setOpenaiSelectedModels] = useState<Set<string>>(
() => new Set()
);
const [providerSortBy, setProviderSortBy] = useState<ProviderSortBy>('name');
const [providerSortDir, setProviderSortDir] = useState<SortDir>('asc');
const [selectedModels, setSelectedModels] = useState<Set<string>>(() => new Set());
const [sheetState, setSheetState] = useState<SheetState>({
open: false,
brand: 'gemini',
@@ -109,85 +168,69 @@ export function ProvidersWorkbenchPage() {
return activeGroup.resources.filter((r) => matchesFilter(r, normalized));
}, [activeGroup, filter]);
const isOpenAI = activeGroup?.id === 'openaiCompatibility';
const availableOpenaiModels = useMemo(() => {
if (!isOpenAI || !activeGroup) return [];
const availableModels = useMemo(() => {
if (!activeGroup) return [];
const seen = new Set<string>();
activeGroup.resources.forEach((r) => {
const cfg = r.raw as OpenAIProviderConfig;
cfg.models?.forEach((m) => {
const name = (m.name ?? '').trim();
if (name) seen.add(name);
});
getResourceModels(r).forEach((name) => seen.add(name));
});
return Array.from(seen).sort();
}, [activeGroup, isOpenAI]);
}, [activeGroup]);
const visibleResources = useMemo(() => {
if (!isOpenAI) return filteredResources;
let arr = filteredResources;
if (openaiSelectedModels.size > 0) {
if (selectedModels.size > 0) {
arr = arr.filter((r) => {
const cfg = r.raw as OpenAIProviderConfig;
return Boolean(
cfg.models?.some((m) => openaiSelectedModels.has((m.name ?? '').trim()))
);
const models = getResourceModels(r);
return models.some((name) => selectedModels.has(name));
});
}
const sorted = [...arr].sort((a, b) => {
let diff = 0;
if (openaiSortBy === 'name') {
const an = (a.name ?? a.identifier ?? '').toLowerCase();
const bn = (b.name ?? b.identifier ?? '').toLowerCase();
diff = an.localeCompare(bn);
} else if (openaiSortBy === 'priority') {
const ap = (a.raw as OpenAIProviderConfig).priority ?? 0;
const bp = (b.raw as OpenAIProviderConfig).priority ?? 0;
if (providerSortBy === 'name') {
diff = getResourceSortName(a).localeCompare(getResourceSortName(b));
} else if (providerSortBy === 'priority') {
const ap = getResourcePriority(a);
const bp = getResourcePriority(b);
diff = ap - bp;
} else {
const aStats = getOpenAIProviderRecentWindowStats(
a.raw as OpenAIProviderConfig,
usageByProvider
);
const bStats = getOpenAIProviderRecentWindowStats(
b.raw as OpenAIProviderConfig,
usageByProvider
);
diff = aStats.success - bStats.success;
diff =
getResourceRecentSuccess(a, usageByProvider) -
getResourceRecentSuccess(b, usageByProvider);
}
return openaiSortDir === 'asc' ? diff : -diff;
if (diff === 0) {
diff = a.originalIndex - b.originalIndex;
}
return providerSortDir === 'asc' ? diff : -diff;
});
return sorted;
}, [
filteredResources,
isOpenAI,
openaiSelectedModels,
openaiSortBy,
openaiSortDir,
providerSortBy,
providerSortDir,
selectedModels,
usageByProvider,
]);
const openaiControls = useMemo<OpenAIPanelControls | undefined>(() => {
if (!isOpenAI) return undefined;
const toolbarControls = useMemo<ProviderPanelControls | undefined>(() => {
if (!activeGroup) return undefined;
return {
sortBy: openaiSortBy,
sortDir: openaiSortDir,
onSortBy: setOpenaiSortBy,
onSortDir: setOpenaiSortDir,
availableModels: availableOpenaiModels,
selectedModels: openaiSelectedModels,
onSelectedModelsChange: setOpenaiSelectedModels,
sortBy: providerSortBy,
sortDir: providerSortDir,
onSortBy: setProviderSortBy,
onSortDir: setProviderSortDir,
availableModels,
selectedModels,
onSelectedModelsChange: setSelectedModels,
};
}, [
availableOpenaiModels,
isOpenAI,
openaiSelectedModels,
openaiSortBy,
openaiSortDir,
activeGroup,
availableModels,
providerSortBy,
providerSortDir,
selectedModels,
]);
const totalResources = useMemo(
@@ -381,7 +424,7 @@ export function ProvidersWorkbenchPage() {
if (!ok) return;
setActiveBrand(brand);
setFilter('');
setOpenaiSelectedModels(new Set());
setSelectedModels(new Set());
if (isSwitching) {
closeSheet();
}
@@ -396,7 +439,7 @@ export function ProvidersWorkbenchPage() {
selectedId={sheetState.open ? sheetState.resource?.id ?? null : null}
disableMutations={disableMutations}
usageByProvider={usageByProvider}
openaiControls={openaiControls}
toolbarControls={toolbarControls}
onView={openView}
onEdit={openEdit}
onDelete={handleDelete}
@@ -10,10 +10,10 @@ import type { ProviderRecentUsageMap } from '@/components/providers/utils';
import type { ProviderBrand, ProviderGroup, ProviderResource } from '../types';
import { ProviderResourceTable } from './ProviderResourceTable';
import {
OpenAIBrandToolbar,
type OpenAISortBy,
ProviderResourceToolbar,
type ProviderSortBy,
type SortDir,
} from './OpenAIBrandToolbar';
} from './ProviderResourceToolbar';
import styles from './ProviderResourcePanel.module.scss';
const LOGOS: Record<ProviderBrand, { src: string; invertOnDark?: boolean }> = {
@@ -25,10 +25,10 @@ const LOGOS: Record<ProviderBrand, { src: string; invertOnDark?: boolean }> = {
ampcode: { src: ampcodeLogo },
};
export interface OpenAIPanelControls {
sortBy: OpenAISortBy;
export interface ProviderPanelControls {
sortBy: ProviderSortBy;
sortDir: SortDir;
onSortBy: (value: OpenAISortBy) => void;
onSortBy: (value: ProviderSortBy) => void;
onSortDir: (value: SortDir) => void;
availableModels: ReadonlyArray<string>;
selectedModels: ReadonlySet<string>;
@@ -43,7 +43,7 @@ interface ProviderResourcePanelProps {
selectedId: string | null;
disableMutations?: boolean;
usageByProvider?: ProviderRecentUsageMap;
openaiControls?: OpenAIPanelControls;
toolbarControls?: ProviderPanelControls;
onView: (resource: ProviderResource) => void;
onEdit: (resource: ProviderResource) => void;
onDelete: (resource: ProviderResource) => void;
@@ -59,7 +59,7 @@ export function ProviderResourcePanel({
selectedId,
disableMutations,
usageByProvider,
openaiControls,
toolbarControls,
onView,
onEdit,
onDelete,
@@ -105,16 +105,17 @@ export function ProviderResourcePanel({
</div>
) : null}
</div>
{openaiControls ? (
{toolbarControls ? (
<div className={styles.headerToolbarRow}>
<OpenAIBrandToolbar
sortBy={openaiControls.sortBy}
sortDir={openaiControls.sortDir}
onSortBy={openaiControls.onSortBy}
onSortDir={openaiControls.onSortDir}
availableModels={openaiControls.availableModels}
selectedModels={openaiControls.selectedModels}
onSelectedModelsChange={openaiControls.onSelectedModelsChange}
<ProviderResourceToolbar
key={group.id}
sortBy={toolbarControls.sortBy}
sortDir={toolbarControls.sortDir}
onSortBy={toolbarControls.onSortBy}
onSortDir={toolbarControls.onSortDir}
availableModels={toolbarControls.availableModels}
selectedModels={toolbarControls.selectedModels}
onSelectedModelsChange={toolbarControls.onSelectedModelsChange}
/>
</div>
) : null}
@@ -7,22 +7,22 @@ import {
} from '@/components/ui/icons';
import { Select } from '@/components/ui/Select';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import styles from './OpenAIBrandToolbar.module.scss';
import styles from './ProviderResourceToolbar.module.scss';
export type OpenAISortBy = 'name' | 'priority' | 'recent-success';
export type ProviderSortBy = 'name' | 'priority' | 'recent-success';
export type SortDir = 'asc' | 'desc';
interface OpenAIBrandToolbarProps {
sortBy: OpenAISortBy;
interface ProviderResourceToolbarProps {
sortBy: ProviderSortBy;
sortDir: SortDir;
onSortBy: (value: OpenAISortBy) => void;
onSortBy: (value: ProviderSortBy) => void;
onSortDir: (value: SortDir) => void;
availableModels: ReadonlyArray<string>;
selectedModels: ReadonlySet<string>;
onSelectedModelsChange: (next: Set<string>) => void;
}
export function OpenAIBrandToolbar({
export function ProviderResourceToolbar({
sortBy,
sortDir,
onSortBy,
@@ -30,7 +30,7 @@ export function OpenAIBrandToolbar({
availableModels,
selectedModels,
onSelectedModelsChange,
}: OpenAIBrandToolbarProps) {
}: ProviderResourceToolbarProps) {
const { t } = useTranslation();
const [filterOpen, setFilterOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
@@ -86,7 +86,7 @@ export function OpenAIBrandToolbar({
<Select
value={sortBy}
options={sortOptions}
onChange={(value) => onSortBy(value as OpenAISortBy)}
onChange={(value) => onSortBy(value as ProviderSortBy)}
ariaLabel={t('providersPage.toolbar.sortBy')}
size="sm"
/>
+1 -1
View File
@@ -36,7 +36,7 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
supportsHeaders: true,
supportsExcludedModels: true,
supportsPriority: true,
supportsTestModel: false,
supportsTestModel: true,
supportsWebsockets: false,
supportsCloak: false,
supportsApiKeyEntries: false,
@@ -76,7 +76,10 @@ function buildInitialForm(
websockets: brand === 'codex' ? false : undefined,
cloak:
brand === 'claude' ? { mode: '', strictMode: false, sensitiveWordsText: '' } : undefined,
testModel: brand === 'openaiCompatibility' || brand === 'claude' ? '' : undefined,
testModel:
brand === 'openaiCompatibility' || brand === 'claude' || brand === 'gemini'
? ''
: undefined,
apiKeyEntries: brand === 'openaiCompatibility' ? [emptyApiKeyEntry()] : undefined,
};
}
@@ -152,7 +155,7 @@ function buildInitialForm(
sensitiveWordsText: (cfg as ProviderKeyConfig).cloak?.sensitiveWords?.join('\n') ?? '',
}
: undefined,
testModel: brand === 'claude' ? '' : undefined,
testModel: brand === 'claude' || brand === 'gemini' ? '' : undefined,
};
}
@@ -381,13 +384,6 @@ export function BaseProviderForm({
if (descriptor.baseUrlRequired && !form.baseUrl.trim()) {
return t('providersPage.form.validation.baseUrlRequired');
}
if (
brand === 'openaiCompatibility' &&
mode === 'create' &&
!form.apiKeyEntries?.some((e) => e.apiKey.trim())
) {
return t('providersPage.form.validation.apiKeyRequired');
}
return null;
};
@@ -421,6 +417,13 @@ export function BaseProviderForm({
form.apiKeyEntries && form.apiKeyEntries.length ? form.apiKeyEntries : [emptyApiKeyEntry()],
[form.apiKeyEntries]
);
const actualApiKeyEntries = form.apiKeyEntries ?? [];
const singleConnectivity =
brand === 'gemini'
? { status: connectivity.geminiStatus, run: connectivity.runGemini }
: brand === 'claude'
? { status: connectivity.claudeStatus, run: connectivity.runClaude }
: null;
const removeApiKeyEntry = (removeIdx: number) => {
setShowPasswords((prev) => {
@@ -437,7 +440,7 @@ export function BaseProviderForm({
});
updateField(
'apiKeyEntries',
apiKeyEntries.filter((_, i) => i !== removeIdx)
actualApiKeyEntries.filter((_, i) => i !== removeIdx)
);
};
@@ -584,7 +587,7 @@ export function BaseProviderForm({
<div className={styles.field}>
<label className={styles.label} htmlFor={`${fid}-testModel`}>
{t('providersPage.form.testModel')}
{brand === 'claude' ? (
{brand === 'claude' || brand === 'gemini' ? (
<span className={styles.labelHint}>
{' '}
· {t('providersPage.form.testModelClaudeHint')}
@@ -599,31 +602,31 @@ export function BaseProviderForm({
disabled={mutating}
ariaLabel={t('providersPage.form.testModel')}
/>
{brand === 'claude' ? (
{singleConnectivity ? (
<div className={styles.connectivityRow}>
<button
type="button"
className={styles.connectivityBtn}
disabled={mutating || connectivity.isTestingAny}
onClick={() => void connectivity.runClaude()}
onClick={() => void singleConnectivity.run()}
>
{connectivity.claudeStatus.state === 'loading' ? (
{singleConnectivity.status.state === 'loading' ? (
<span className={`${styles.statusIcon} ${styles.statusIconLoading}`}>
<IconLoader2 size={14} />
</span>
) : null}
<span>{t('providersPage.connectivity.test')}</span>
</button>
<ConnectivityStatusIcon state={connectivity.claudeStatus.state} />
{connectivity.claudeStatus.state === 'success' ? (
<ConnectivityStatusIcon state={singleConnectivity.status.state} />
{singleConnectivity.status.state === 'success' ? (
<span className={styles.connectivityHintSuccess}>
{t('providersPage.connectivity.success')}
</span>
) : null}
</div>
) : null}
{brand === 'claude' && connectivity.claudeStatus.state === 'error' ? (
<div className={styles.connectivityError}>{connectivity.claudeStatus.message}</div>
{singleConnectivity?.status.state === 'error' ? (
<div className={styles.connectivityError}>{singleConnectivity.status.message}</div>
) : null}
</div>
) : null}
@@ -676,7 +679,9 @@ export function BaseProviderForm({
type="button"
className={styles.addBtn}
disabled={mutating}
onClick={() => updateField('apiKeyEntries', [...apiKeyEntries, emptyApiKeyEntry()])}
onClick={() =>
updateField('apiKeyEntries', [...actualApiKeyEntries, emptyApiKeyEntry()])
}
>
<IconPlus size={12} />
<span>{t('providersPage.form.addApiKeyEntry')}</span>
@@ -724,7 +729,7 @@ export function BaseProviderForm({
<button
type="button"
className={styles.removeBtn}
disabled={mutating || apiKeyEntries.length <= 1}
disabled={mutating || actualApiKeyEntries.length === 0}
onClick={() => removeApiKeyEntry(realIdx)}
>
<IconX size={12} />
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import {
buildClaudeMessagesEndpoint,
buildGeminiGenerateContentEndpoint,
buildOpenAIChatCompletionsEndpoint,
} from '@/components/providers/utils';
import { buildHeaderObject, hasHeader } from '@/utils/headers';
@@ -25,6 +26,18 @@ const errorMessage = (err: unknown): string => {
return '';
};
const requestFailureMessage = (err: unknown, messages: ConnectivityErrorMessages): string => {
const raw = errorMessage(err);
const isTimeout =
(typeof err === 'object' &&
err !== null &&
'code' in err &&
String((err as { code?: string }).code) === 'ECONNABORTED') ||
raw.toLowerCase().includes('timeout');
return isTimeout ? messages.timeout(DEFAULT_TIMEOUT_MS / 1000) : raw || messages.requestFailed;
};
const pickModel = (testModel: string | undefined, models: ModelEntryInput[]): string => {
const trimmed = (testModel ?? '').trim();
if (trimmed) return trimmed;
@@ -65,10 +78,12 @@ export interface ConnectivityErrorMessages {
export interface UseConnectivityTestResult {
openaiStatuses: ConnectivityStatus[];
geminiStatus: ConnectivityStatus;
claudeStatus: ConnectivityStatus;
isTestingAny: boolean;
runOpenAIKey: (idx: number) => Promise<boolean>;
runOpenAIAllKeys: () => Promise<void>;
runGemini: () => Promise<void>;
runClaude: () => Promise<void>;
}
@@ -93,6 +108,7 @@ export function useConnectivityTest(
const [openaiStatuses, setOpenaiStatuses] = useState<ConnectivityStatus[]>(() =>
Array.from({ length: entriesCount }, () => IDLE)
);
const [geminiStatus, setGeminiStatus] = useState<ConnectivityStatus>(IDLE);
const [claudeStatus, setClaudeStatus] = useState<ConnectivityStatus>(IDLE);
const [inFlight, setInFlight] = useState(0);
@@ -133,14 +149,23 @@ export function useConnectivityTest(
const signature = useMemo(() => {
const h = formHeaders.map((it) => `${it.key}:${it.value}`).join('|');
const m = models.map((it) => `${it.name}:${it.alias ?? ''}`).join('|');
return `${baseUrl}||${(testModel ?? '').trim()}||${h}||${m}`;
}, [baseUrl, testModel, formHeaders, models]);
return [
baseUrl,
(testModel ?? '').trim(),
apiKey ?? '',
fallbackApiKey ?? '',
authIndex ?? '',
h,
m,
].join('||');
}, [apiKey, authIndex, baseUrl, fallbackApiKey, testModel, formHeaders, models]);
const lastSignatureRef = useRef(signature);
useEffect(() => {
if (lastSignatureRef.current === signature) return;
lastSignatureRef.current = signature;
setOpenaiStatuses((prev) => prev.map(() => IDLE));
setGeminiStatus(IDLE);
setClaudeStatus(IDLE);
}, [signature]);
@@ -228,18 +253,9 @@ export function useConnectivityTest(
updateOpenaiStatus(idx, { state: 'success', message: '' });
return true;
} catch (err) {
const raw = errorMessage(err);
const isTimeout =
(typeof err === 'object' &&
err !== null &&
'code' in err &&
String((err as { code?: string }).code) === 'ECONNABORTED') ||
raw.toLowerCase().includes('timeout');
updateOpenaiStatus(idx, {
state: 'error',
message: isTimeout
? messages.timeout(DEFAULT_TIMEOUT_MS / 1000)
: raw || messages.requestFailed,
message: requestFailureMessage(err, messages),
});
return false;
} finally {
@@ -266,6 +282,75 @@ export function useConnectivityTest(
await Promise.all(entries.map((_, idx) => runOpenAIKey(idx)));
}, [apiKeyEntries, brand, runOpenAIKey]);
const runGemini = useCallback(async (): Promise<void> => {
if (brand !== 'gemini') return;
const model = pickModel(testModel, models);
if (!model) {
setGeminiStatus({ state: 'error', message: messages.modelRequired });
return;
}
const endpoint = buildGeminiGenerateContentEndpoint(baseUrl ?? '', model);
if (!endpoint) {
setGeminiStatus({ state: 'error', message: messages.endpointInvalid });
return;
}
const customHeaders = buildHeaderObject(formHeaders);
const explicitKey = (apiKey ?? '').trim();
const persistedKey = (fallbackApiKey ?? '').trim();
const hasApiKeyHeader = hasHeader(customHeaders, 'x-goog-api-key');
const resolvedKey = explicitKey || persistedKey;
const resolvedAuthIndex = (authIndex ?? '').trim() || undefined;
if (!resolvedKey && !hasApiKeyHeader && !resolvedAuthIndex) {
setGeminiStatus({ state: 'error', message: messages.apiKeyRequired });
return;
}
const headerObj: Record<string, string> = {
'Content-Type': 'application/json',
...customHeaders,
};
if (!hasHeader(headerObj, 'x-goog-api-key')) {
if (resolvedKey) {
headerObj['x-goog-api-key'] = resolvedKey;
} else if (resolvedAuthIndex) {
headerObj['x-goog-api-key'] = '$TOKEN$';
}
}
setGeminiStatus({ state: 'loading', message: '' });
setInFlight((n) => n + 1);
try {
const result = await apiCallApi.request(
{
authIndex: resolvedAuthIndex,
method: 'POST',
url: endpoint,
header: headerObj,
data: JSON.stringify({
contents: [{ parts: [{ text: 'Hi' }] }],
generationConfig: { maxOutputTokens: 8 },
}),
},
{ timeout: DEFAULT_TIMEOUT_MS }
);
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
setGeminiStatus({ state: 'success', message: '' });
} catch (err) {
setGeminiStatus({
state: 'error',
message: requestFailureMessage(err, messages),
});
} finally {
setInFlight((n) => n - 1);
}
}, [apiKey, authIndex, baseUrl, brand, fallbackApiKey, formHeaders, messages, models, testModel]);
const runClaude = useCallback(async (): Promise<void> => {
if (brand !== 'claude') return;
@@ -328,18 +413,9 @@ export function useConnectivityTest(
}
setClaudeStatus({ state: 'success', message: '' });
} catch (err) {
const raw = errorMessage(err);
const isTimeout =
(typeof err === 'object' &&
err !== null &&
'code' in err &&
String((err as { code?: string }).code) === 'ECONNABORTED') ||
raw.toLowerCase().includes('timeout');
setClaudeStatus({
state: 'error',
message: isTimeout
? messages.timeout(DEFAULT_TIMEOUT_MS / 1000)
: raw || messages.requestFailed,
message: requestFailureMessage(err, messages),
});
} finally {
setInFlight((n) => n - 1);
@@ -348,10 +424,12 @@ export function useConnectivityTest(
return {
openaiStatuses,
geminiStatus,
claudeStatus,
isTestingAny: inFlight > 0,
runOpenAIKey,
runOpenAIAllKeys,
runGemini,
runClaude,
};
}
+1 -1
View File
@@ -121,7 +121,7 @@ export interface ProviderEntryFormInput {
websockets?: boolean;
/** Claude 专属 */
cloak?: CloakInput;
/** OpenAI 专属 */
/** OpenAI persists this; Gemini/Claude use it for one-off connectivity tests. */
testModel?: string;
apiKeyEntries?: ApiKeyEntryInput[];
}
@@ -161,7 +161,7 @@ const buildOpenAIConfig = (
name: input.name.trim(),
baseUrl: input.baseUrl.trim(),
prefix: input.prefix.trim() || undefined,
apiKeyEntries: apiKeyEntries.length ? apiKeyEntries : (existing?.apiKeyEntries ?? []),
apiKeyEntries,
disabled: input.disabled,
headers: Object.keys(headers).length ? headers : undefined,
models: models.length ? models : undefined,
@@ -443,7 +443,7 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
updateConfigValue('vertex-api-key', next);
clearCache('vertex-api-key');
} else if (sel.brand === 'openaiCompatibility') {
await providersApi.deleteOpenAIProvider(sel.name);
await providersApi.deleteOpenAIProvider(sel.index);
const next = (config?.openaiCompatibility ?? []).filter((_, i) => i !== sel.index);
updateConfigValue('openai-compatibility', next);
clearCache('openai-compatibility');
+4 -1
View File
@@ -143,7 +143,7 @@
"quick_actions": "Quick Actions",
"current_config": "Current Configuration",
"management_keys": "Management Keys",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"oauth_credentials": "OAuth Credentials",
"edit_settings": "Edit Settings",
"routing_strategy": "Routing Strategy",
@@ -562,6 +562,7 @@
"alias_label": "Model aliases",
"alias_name_placeholder": "Source model name",
"alias_placeholder": "Alias (required)",
"duplicate_alias": "Each alias must be unique for this provider.",
"alias_fork_label": "Keep original",
"add_alias": "Add alias",
"save": "Save/Update",
@@ -735,6 +736,8 @@
"runtime_home": "Current runtime: Home",
"refresh_button": "Refresh Logs",
"clear_button": "Clear Logs",
"fullscreen_button": "Full Screen",
"exit_fullscreen_button": "Exit Full Screen",
"download_button": "Download Logs",
"error_log_button": "Select Error Log",
"error_logs_modal_title": "Error Request Logs",
+4 -1
View File
@@ -143,7 +143,7 @@
"quick_actions": "Быстрые действия",
"current_config": "Текущая конфигурация",
"management_keys": "Ключи управления",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"oauth_credentials": "Учётные данные OAuth",
"edit_settings": "Изменить настройки",
"routing_strategy": "Стратегия маршрутизации",
@@ -559,6 +559,7 @@
"alias_label": "Псевдонимы моделей",
"alias_name_placeholder": "Исходное имя модели",
"alias_placeholder": "Псевдоним (обязательно)",
"duplicate_alias": "Каждый псевдоним этого провайдера должен быть уникальным.",
"alias_fork_label": "Сохранить оригинал",
"add_alias": "Добавить псевдоним",
"save": "Сохранить/обновить",
@@ -732,6 +733,8 @@
"runtime_home": "Текущий режим: Home",
"refresh_button": "Обновить журналы",
"clear_button": "Очистить журналы",
"fullscreen_button": "На весь экран",
"exit_fullscreen_button": "Выйти из полноэкранного режима",
"download_button": "Скачать журналы",
"error_log_button": "Выбрать журнал ошибок",
"error_logs_modal_title": "Журналы ошибок запросов",
+4 -1
View File
@@ -143,7 +143,7 @@
"quick_actions": "快捷操作",
"current_config": "当前配置",
"management_keys": "管理密钥",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"oauth_credentials": "OAuth 凭证",
"edit_settings": "编辑设置",
"routing_strategy": "路由策略",
@@ -562,6 +562,7 @@
"alias_label": "模型别名",
"alias_name_placeholder": "原模型名称",
"alias_placeholder": "别名 (必填)",
"duplicate_alias": "同一提供商下的别名不能重复。",
"alias_fork_label": "保留原名",
"add_alias": "添加别名",
"save": "保存/更新",
@@ -735,6 +736,8 @@
"runtime_home": "当前运行端:Home",
"refresh_button": "刷新日志",
"clear_button": "清空日志",
"fullscreen_button": "全屏显示",
"exit_fullscreen_button": "退出全屏",
"download_button": "下载日志",
"error_log_button": "选择错误日志",
"error_logs_modal_title": "错误请求日志",
+4 -1
View File
@@ -143,7 +143,7 @@
"quick_actions": "快速操作",
"current_config": "目前設定",
"management_keys": "管理金鑰",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"oauth_credentials": "OAuth 憑證",
"edit_settings": "編輯設定",
"routing_strategy": "路由策略",
@@ -562,6 +562,7 @@
"alias_label": "模型別名",
"alias_name_placeholder": "原模型名稱",
"alias_placeholder": "別名(必填)",
"duplicate_alias": "同一供應商下的別名不能重複。",
"alias_fork_label": "保留原名",
"add_alias": "新增別名",
"save": "儲存/更新",
@@ -761,6 +762,8 @@
"runtime_home": "目前執行端:Home",
"refresh_button": "重新整理記錄",
"clear_button": "清空記錄",
"fullscreen_button": "全螢幕顯示",
"exit_fullscreen_button": "退出全螢幕",
"download_button": "下載記錄",
"error_log_button": "選擇錯誤記錄",
"error_logs_modal_title": "錯誤請求記錄",
+7 -30
View File
@@ -12,6 +12,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useAuthStore, useNotificationStore } from '@/stores';
import { authFilesApi } from '@/services/api';
import { buildOAuthProviderOptions, normalizeProviderKey } from '@/features/authFiles/constants';
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
import styles from './AuthFilesOAuthExcludedEditPage.module.scss';
@@ -19,22 +20,6 @@ type AuthFileModelItem = { id: string; display_name?: string; type?: string; own
type LocationState = { fromAuthFiles?: boolean } | null;
const OAUTH_PROVIDER_PRESETS = [
'gemini-cli',
'vertex',
'aistudio',
'antigravity',
'claude',
'codex',
'qwen',
'kimi',
'iflow',
];
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
export function AuthFilesOAuthExcludedEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -76,16 +61,7 @@ export function AuthFilesOAuthExcludedEditPage() {
}
});
const normalizedExtras = Array.from(extraProviders)
.map((value) => value.trim())
.filter((value) => value && !OAUTH_PROVIDER_EXCLUDES.has(value.toLowerCase()));
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => value.toLowerCase()));
const extraList = normalizedExtras
.filter((value) => !baseSet.has(value.toLowerCase()))
.sort((a, b) => a.localeCompare(b));
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
return buildOAuthProviderOptions(extraProviders);
}, [excluded, files, modelAlias]);
const getTypeLabel = useCallback(
@@ -223,7 +199,7 @@ export function AuthFilesOAuthExcludedEditPage() {
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
if (status === 400 || status === 404) {
setModelsList([]);
setModelsError('unsupported');
return;
@@ -281,7 +257,7 @@ export function AuthFilesOAuthExcludedEditPage() {
try {
if (models.length) {
await authFilesApi.saveOauthExcludedModels(normalizedProvider, models);
} else {
} else if (isEditing) {
await authFilesApi.deleteOauthExcludedEntry(normalizedProvider);
}
showNotification(t('oauth_excluded.save_success'), 'success');
@@ -292,7 +268,7 @@ export function AuthFilesOAuthExcludedEditPage() {
} finally {
setSaving(false);
}
}, [handleBack, provider, selectedModels, showNotification, t]);
}, [handleBack, isEditing, provider, selectedModels, showNotification, t]);
const canSave = !disableControls && !saving && !excludedUnsupported;
@@ -352,7 +328,8 @@ export function AuthFilesOAuthExcludedEditPage() {
{providerOptions.length > 0 && (
<div className={styles.tagList}>
{providerOptions.map((option) => {
const isActive = normalizeProviderKey(provider) === option.toLowerCase();
const isActive =
normalizeProviderKey(provider) === normalizeProviderKey(option);
return (
<button
key={option}
+31 -37
View File
@@ -11,6 +11,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useAuthStore, useNotificationStore } from '@/stores';
import { authFilesApi } from '@/services/api';
import { buildOAuthProviderOptions, normalizeProviderKey } from '@/features/authFiles/constants';
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
import { generateId } from '@/utils/helpers';
import styles from './AuthFilesOAuthModelAliasEditPage.module.scss';
@@ -21,22 +22,6 @@ type LocationState = { fromAuthFiles?: boolean } | null;
type OAuthModelMappingFormEntry = OAuthModelAliasEntry & { id: string };
const OAUTH_PROVIDER_PRESETS = [
'gemini-cli',
'vertex',
'aistudio',
'antigravity',
'claude',
'codex',
'qwen',
'kimi',
'iflow',
];
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({
id: generateId(),
name: '',
@@ -76,7 +61,9 @@ export function AuthFilesOAuthModelAliasEditPage() {
const [initialLoading, setInitialLoading] = useState(true);
const [modelAliasUnsupported, setModelAliasUnsupported] = useState(false);
const [mappings, setMappings] = useState<OAuthModelMappingFormEntry[]>([buildEmptyMappingEntry()]);
const [mappings, setMappings] = useState<OAuthModelMappingFormEntry[]>([
buildEmptyMappingEntry(),
]);
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<'unsupported' | null>(null);
@@ -99,16 +86,7 @@ export function AuthFilesOAuthModelAliasEditPage() {
}
});
const normalizedExtras = Array.from(extraProviders)
.map((value) => value.trim())
.filter((value) => value && !OAUTH_PROVIDER_EXCLUDES.has(value.toLowerCase()));
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => value.toLowerCase()));
const extraList = normalizedExtras
.filter((value) => !baseSet.has(value.toLowerCase()))
.sort((a, b) => a.localeCompare(b));
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
return buildOAuthProviderOptions(extraProviders);
}, [excluded, files, modelAlias]);
const getTypeLabel = useCallback(
@@ -123,6 +101,10 @@ export function AuthFilesOAuthModelAliasEditPage() {
);
const resolvedProviderKey = useMemo(() => normalizeProviderKey(provider), [provider]);
const isEditing = useMemo(() => {
if (!resolvedProviderKey) return false;
return Object.prototype.hasOwnProperty.call(modelAlias, resolvedProviderKey);
}, [modelAlias, resolvedProviderKey]);
const title = useMemo(() => t('oauth_model_alias.add_title'), [t]);
const headerHint = useMemo(() => {
if (!provider.trim()) {
@@ -248,7 +230,7 @@ export function AuthFilesOAuthModelAliasEditPage() {
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
if (status === 400 || status === 404) {
setModelsList([]);
setModelsError('unsupported');
return;
@@ -303,30 +285,39 @@ export function AuthFilesOAuthModelAliasEditPage() {
}, []);
const handleSave = useCallback(async () => {
const channel = provider.trim();
const channel = normalizeProviderKey(provider);
if (!channel) {
showNotification(t('oauth_model_alias.provider_required'), 'error');
return;
}
const seen = new Set<string>();
const seenAlias = new Set<string>();
let hasDuplicateAlias = false;
const normalized = 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);
const aliasKey = alias.toLowerCase();
if (seenAlias.has(aliasKey)) {
hasDuplicateAlias = true;
return null;
}
seenAlias.add(aliasKey);
return entry.fork ? { name, alias, fork: true } : { name, alias };
})
.filter(Boolean) as OAuthModelAliasEntry[];
if (hasDuplicateAlias) {
showNotification(t('oauth_model_alias.duplicate_alias'), 'error');
return;
}
setSaving(true);
try {
if (normalized.length) {
await authFilesApi.saveOauthModelAlias(channel, normalized);
} else {
} else if (isEditing) {
await authFilesApi.deleteOauthModelAlias(channel);
}
showNotification(t('oauth_model_alias.save_success'), 'success');
@@ -337,7 +328,7 @@ export function AuthFilesOAuthModelAliasEditPage() {
} finally {
setSaving(false);
}
}, [handleBack, mappings, provider, showNotification, t]);
}, [handleBack, isEditing, mappings, provider, showNotification, t]);
const canSave = !disableControls && !saving && !modelAliasUnsupported;
@@ -378,7 +369,9 @@ export function AuthFilesOAuthModelAliasEditPage() {
<div className={styles.settingsSection}>
<div className={styles.settingsRow}>
<div className={styles.settingsInfo}>
<div className={styles.settingsLabel}>{t('oauth_model_alias.provider_label')}</div>
<div className={styles.settingsLabel}>
{t('oauth_model_alias.provider_label')}
</div>
<div className={styles.settingsDesc}>{t('oauth_model_alias.provider_hint')}</div>
</div>
<div className={styles.settingsControl}>
@@ -397,7 +390,8 @@ export function AuthFilesOAuthModelAliasEditPage() {
{providerOptions.length > 0 && (
<div className={styles.tagList}>
{providerOptions.map((option) => {
const isActive = normalizeProviderKey(provider) === option.toLowerCase();
const isActive =
normalizeProviderKey(provider) === normalizeProviderKey(option);
return (
<button
key={option}
+26 -1
View File
@@ -1,4 +1,13 @@
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
Suspense,
lazy,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import type { ReactCodeMirrorRef } from '@uiw/react-codemirror';
@@ -16,6 +25,7 @@ import {
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
import { DiffModal } from '@/components/config/DiffModal';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { useVisualConfig } from '@/hooks/useVisualConfig';
import { useNotificationStore, useAuthStore, useThemeStore, useConfigStore } from '@/stores';
import { configFileApi } from '@/services/api/configFile';
@@ -97,6 +107,21 @@ export function ConfigPage() {
const hasVisualValidationErrors =
activeTab === 'visual' &&
(Object.values(visualValidationErrors).some(Boolean) || visualHasPayloadValidationErrors);
const unsavedChangesDialog = useMemo(
() => ({
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.confirm'),
cancelText: t('common.cancel'),
}),
[t]
);
useUnsavedChangesGuard({
enabled: isCurrentLayer,
shouldBlock: isDirty,
dialog: unsavedChangesDialog,
});
const loadConfig = useCallback(async () => {
setLoading(true);
+78 -37
View File
@@ -1,14 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
IconKey,
IconBot,
IconFileText,
IconSatellite
} from '@/components/ui/icons';
import { IconKey, IconBot, IconFileText, IconSatellite } from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useModelsStore } from '@/stores';
import { apiKeysApi, providersApi, authFilesApi } from '@/services/api';
import { apiKeysApi, providersApi, authFilesApi, ampcodeApi } from '@/services/api';
import type { AmpcodeConfig } from '@/types';
import { formatDateValue } from '@/utils/format';
import styles from './DashboardPage.module.scss';
interface QuickStat {
@@ -24,11 +21,23 @@ interface ProviderStats {
gemini: number | null;
codex: number | null;
claude: number | null;
vertex: number | null;
openai: number | null;
ampcode: number | null;
}
type TimeOfDay = 'morning' | 'afternoon' | 'evening' | 'night';
const countAmpcodeConfig = (value: AmpcodeConfig | undefined): number => {
if (!value) return 0;
if (value.upstreamUrl?.trim()) return 1;
if (value.upstreamApiKey?.trim()) return 1;
if ((value.upstreamApiKeys?.length ?? 0) > 0) return 1;
if ((value.modelMappings?.length ?? 0) > 0) return 1;
if (value.forceModelMappings === true) return 1;
return 0;
};
function getTimeOfDay(): TimeOfDay {
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) return 'morning';
@@ -54,14 +63,16 @@ export function DashboardPage() {
authFiles: number | null;
}>({
apiKeys: null,
authFiles: null
authFiles: null,
});
const [providerStats, setProviderStats] = useState<ProviderStats>({
gemini: null,
codex: null,
claude: null,
openai: null
vertex: null,
openai: null,
ampcode: null,
});
const [loading, setLoading] = useState(true);
@@ -151,25 +162,38 @@ export function DashboardPage() {
const fetchStats = async () => {
setLoading(true);
try {
const [keysRes, filesRes, geminiRes, codexRes, claudeRes, openaiRes] = await Promise.allSettled([
const [
keysRes,
filesRes,
geminiRes,
codexRes,
claudeRes,
vertexRes,
openaiRes,
ampcodeRes,
] = await Promise.allSettled([
apiKeysApi.list(),
authFilesApi.list(),
providersApi.getGeminiKeys(),
providersApi.getCodexConfigs(),
providersApi.getClaudeConfigs(),
providersApi.getOpenAIProviders()
providersApi.getVertexConfigs(),
providersApi.getOpenAIProviders(),
ampcodeApi.getAmpcode(),
]);
setStats({
apiKeys: keysRes.status === 'fulfilled' ? keysRes.value.length : null,
authFiles: filesRes.status === 'fulfilled' ? filesRes.value.files.length : null
authFiles: filesRes.status === 'fulfilled' ? filesRes.value.files.length : null,
});
setProviderStats({
gemini: geminiRes.status === 'fulfilled' ? geminiRes.value.length : null,
codex: codexRes.status === 'fulfilled' ? codexRes.value.length : null,
claude: claudeRes.status === 'fulfilled' ? claudeRes.value.length : null,
openai: openaiRes.status === 'fulfilled' ? openaiRes.value.length : null
vertex: vertexRes.status === 'fulfilled' ? vertexRes.value.length : null,
openai: openaiRes.status === 'fulfilled' ? openaiRes.value.length : null,
ampcode: ampcodeRes.status === 'fulfilled' ? countAmpcodeConfig(ampcodeRes.value) : null,
});
} finally {
setLoading(false);
@@ -189,17 +213,23 @@ export function DashboardPage() {
providerStats.gemini !== null &&
providerStats.codex !== null &&
providerStats.claude !== null &&
providerStats.openai !== null;
providerStats.vertex !== null &&
providerStats.openai !== null &&
providerStats.ampcode !== null;
const hasProviderStats =
providerStats.gemini !== null ||
providerStats.codex !== null ||
providerStats.claude !== null ||
providerStats.openai !== null;
providerStats.vertex !== null ||
providerStats.openai !== null ||
providerStats.ampcode !== null;
const totalProviderKeys = providerStatsReady
? (providerStats.gemini ?? 0) +
(providerStats.codex ?? 0) +
(providerStats.claude ?? 0) +
(providerStats.openai ?? 0)
(providerStats.vertex ?? 0) +
(providerStats.openai ?? 0) +
(providerStats.ampcode ?? 0)
: 0;
const quickStats: QuickStat[] = [
@@ -209,7 +239,7 @@ export function DashboardPage() {
icon: <IconKey size={24} />,
path: '/config',
loading: loading && stats.apiKeys === null,
sublabel: t('nav.config_management')
sublabel: t('nav.config_management'),
},
{
label: t('nav.ai_providers'),
@@ -222,9 +252,11 @@ export function DashboardPage() {
gemini: providerStats.gemini ?? '-',
codex: providerStats.codex ?? '-',
claude: providerStats.claude ?? '-',
openai: providerStats.openai ?? '-'
vertex: providerStats.vertex ?? '-',
openai: providerStats.openai ?? '-',
ampcode: providerStats.ampcode ?? '-',
})
: undefined
: undefined,
},
{
label: t('nav.auth_files'),
@@ -232,7 +264,7 @@ export function DashboardPage() {
icon: <IconFileText size={24} />,
path: '/auth-files',
loading: loading && stats.authFiles === null,
sublabel: t('dashboard.oauth_credentials')
sublabel: t('dashboard.oauth_credentials'),
},
{
label: t('dashboard.available_models'),
@@ -240,8 +272,8 @@ export function DashboardPage() {
icon: <IconSatellite size={24} />,
path: '/system',
loading: modelsLoading,
sublabel: t('dashboard.available_models_desc')
}
sublabel: t('dashboard.available_models_desc'),
},
];
const routingStrategyRaw = config?.routingStrategy?.trim() || '';
@@ -268,13 +300,14 @@ export function DashboardPage() {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
day: 'numeric',
});
const formattedTime = currentTime.toLocaleTimeString(i18n.language, {
hour: '2-digit',
minute: '2-digit'
minute: '2-digit',
});
const serverBuildDateDisplay = formatDateValue(serverBuildDate, i18n.language);
return (
<div className={styles.dashboard}>
@@ -321,10 +354,8 @@ export function DashboardPage() {
)}
</span>
</div>
{serverBuildDate && (
<span className={styles.buildDate}>
{new Date(serverBuildDate).toLocaleDateString(i18n.language)}
</span>
{serverBuildDateDisplay && (
<span className={styles.buildDate}>{serverBuildDateDisplay}</span>
)}
</div>
</section>
@@ -342,9 +373,7 @@ export function DashboardPage() {
>
<div className={styles.bentoIcon}>{stat.icon}</div>
<div className={styles.bentoContent}>
<span className={styles.bentoValue}>
{stat.loading ? '...' : stat.value}
</span>
<span className={styles.bentoValue}>{stat.loading ? '...' : stat.value}</span>
<span className={styles.bentoLabel}>{stat.label}</span>
{stat.sublabel && !stat.loading && (
<span className={styles.bentoSublabel}>{stat.sublabel}</span>
@@ -362,23 +391,33 @@ export function DashboardPage() {
<div className={styles.configPillGrid}>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.debug_enable')}</span>
<span className={`${styles.configPillValue} ${config.debug ? styles.on : styles.off}`}>
<span
className={`${styles.configPillValue} ${config.debug ? styles.on : styles.off}`}
>
{config.debug ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.logging_to_file_enable')}</span>
<span className={`${styles.configPillValue} ${config.loggingToFile ? styles.on : styles.off}`}>
<span className={styles.configPillLabel}>
{t('basic_settings.logging_to_file_enable')}
</span>
<span
className={`${styles.configPillValue} ${config.loggingToFile ? styles.on : styles.off}`}
>
{config.loggingToFile ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.retry_count_label')}</span>
<span className={styles.configPillLabel}>
{t('basic_settings.retry_count_label')}
</span>
<span className={styles.configPillValue}>{config.requestRetry ?? 0}</span>
</div>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.ws_auth_enable')}</span>
<span className={`${styles.configPillValue} ${config.wsAuth ? styles.on : styles.off}`}>
<span
className={`${styles.configPillValue} ${config.wsAuth ? styles.on : styles.off}`}
>
{config.wsAuth ? t('common.yes') : t('common.no')}
</span>
</div>
@@ -390,7 +429,9 @@ export function DashboardPage() {
</div>
{config.proxyUrl && (
<div className={`${styles.configPill} ${styles.configPillWide}`}>
<span className={styles.configPillLabel}>{t('basic_settings.proxy_url_label')}</span>
<span className={styles.configPillLabel}>
{t('basic_settings.proxy_url_label')}
</span>
<span className={styles.configPillMono}>{config.proxyUrl}</span>
</div>
)}
+47 -2
View File
@@ -547,8 +547,9 @@
}
.requestIdBadge {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 11px;
color: #0891b2;
background: rgba(8, 145, 178, 0.1);
@@ -753,3 +754,47 @@
height: 280px;
}
}
.logCardFullscreen {
position: fixed;
inset: 0;
z-index: $z-modal - 1;
min-height: 0;
width: 100vw;
height: 100vh;
padding: $spacing-lg;
border-radius: 0;
border: none;
overflow: hidden;
background: var(--bg-primary);
@include mobile {
padding: $spacing-md;
min-height: 0;
overflow: hidden;
}
}
:global(body.logs-fullscreen-active) :global(.page-transition__layer) {
transform: none !important;
}
.logPanelFullscreen {
flex: 1 1 auto;
min-height: 0;
height: auto;
max-height: none;
resize: none;
@include tablet {
min-height: 0;
max-height: none;
}
@include mobile {
flex: 1 1 auto;
min-height: 0;
height: auto;
max-height: none;
}
}
+161 -78
View File
@@ -7,12 +7,15 @@ import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { lockScroll, unlockScroll } from '@/components/ui/scrollLock';
import {
IconChevronDown,
IconChevronUp,
IconCode,
IconDownload,
IconEyeOff,
IconMaximize2,
IconMinimize2,
IconRefreshCw,
IconSearch,
IconSlidersHorizontal,
@@ -29,12 +32,7 @@ import { copyToClipboard } from '@/utils/clipboard';
import { downloadBlob } from '@/utils/download';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
import { formatUnixTimestamp } from '@/utils/format';
import {
HTTP_METHODS,
STATUS_GROUPS,
resolveStatusGroup,
type LogState,
} from './hooks/logTypes';
import { HTTP_METHODS, STATUS_GROUPS, resolveStatusGroup, type LogState } from './hooks/logTypes';
import { parseLogLine } from './hooks/logParsing';
import { useLogFilters } from './hooks/useLogFilters';
import { isNearBottom, useLogScroller } from './hooks/useLogScroller';
@@ -52,6 +50,37 @@ const MAX_BUFFER_LINES = 10000;
const LONG_PRESS_MS = 650;
const LONG_PRESS_MOVE_THRESHOLD = 10;
const getIncrementalAfter = (cursor: LogsQuery['after']): LogsQuery['after'] => {
if (typeof cursor !== 'number') return cursor;
return cursor > 1 ? cursor - 1 : undefined;
};
const findLineOverlap = (currentLines: string[], incomingLines: string[]): number => {
const maxOverlap = Math.min(currentLines.length, incomingLines.length);
for (let size = maxOverlap; size > 0; size -= 1) {
let matched = true;
for (let i = 0; i < size; i += 1) {
if (currentLines[currentLines.length - size + i] !== incomingLines[i]) {
matched = false;
break;
}
}
if (matched) return size;
}
return 0;
};
const mergeIncrementalLines = (currentLines: string[], incomingLines: string[]): string[] => {
if (currentLines.length === 0 || incomingLines.length === 0) {
return [...currentLines, ...incomingLines];
}
const overlap = findLineOverlap(currentLines, incomingLines);
return [...currentLines, ...incomingLines.slice(overlap)];
};
const getErrorMessage = (err: unknown): string => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
@@ -66,7 +95,7 @@ const getErrorPayloadText = (err: unknown): string => {
if (typeof err !== 'object' || err === null) return '';
const payloads = [
(err as { data?: unknown }).data,
(err as { details?: unknown }).details
(err as { details?: unknown }).details,
].filter((payload) => payload !== undefined);
return payloads
.map((payload) => {
@@ -122,6 +151,7 @@ export function LogsPage() {
const [errorLogsError, setErrorLogsError] = useState('');
const [requestLogId, setRequestLogId] = useState<string | null>(null);
const [requestLogDownloading, setRequestLogDownloading] = useState(false);
const [fullscreenLogs, setFullscreenLogs] = useState(false);
const logScrollerRef = useRef<ReturnType<typeof useLogScroller> | null>(null);
const requestLogHomeIpByIdRef = useRef<Record<string, string>>({});
@@ -184,7 +214,7 @@ export function LogsPage() {
const params: LogsQuery =
incremental && latestCursorRef.current
? { after: latestCursorRef.current, limit: MAX_BUFFER_LINES }
? { after: getIncrementalAfter(latestCursorRef.current), limit: MAX_BUFFER_LINES }
: { limit: MAX_BUFFER_LINES };
const data = await logsApi.fetchLogs(params);
setFileLoggingRequired(false);
@@ -209,7 +239,7 @@ export function LogsPage() {
// 增量更新:追加新日志并限制缓冲区大小(避免内存与渲染膨胀)
setLogState((prev) => {
const prevRenderedCount = prev.buffer.length - prev.visibleFrom;
const combined = [...prev.buffer, ...newLines];
const combined = mergeIncrementalLines(prev.buffer, newLines);
const dropCount = Math.max(combined.length - MAX_BUFFER_LINES, 0);
const buffer = dropCount > 0 ? combined.slice(dropCount) : combined;
let visibleFrom = Math.max(prev.visibleFrom - dropCount, 0);
@@ -442,14 +472,14 @@ export function LogsPage() {
return {
filteredParsedLines: filteredParsed,
filteredLines: filteredParsed.map((line) => line.raw),
removedCount: Math.max(baseLines.length - filteredParsed.length, 0)
removedCount: Math.max(baseLines.length - filteredParsed.length, 0),
};
}, [
baseLines,
filters.methodFilterSet,
filters.pathFilterSet,
filters.statusFilterSet,
parsedSearchLines
parsedSearchLines,
]);
const parsedVisibleLines = useMemo(
@@ -466,7 +496,7 @@ export function LogsPage() {
isSearching,
filteredLineCount: filteredLines.length,
hasStructuredFilters: filters.hasStructuredFilters,
showRawLogs
showRawLogs,
});
logScrollerRef.current = scroller;
@@ -535,7 +565,7 @@ export function LogsPage() {
);
downloadBlob({
filename: `request-${id}.log`,
blob: new Blob([response.data], { type: 'text/plain' })
blob: new Blob([response.data], { type: 'text/plain' }),
});
showNotification(t('logs.request_log_download_success'), 'success');
setRequestLogId(null);
@@ -559,13 +589,32 @@ export function LogsPage() {
};
}, []);
useEffect(() => {
if (!fullscreenLogs) return;
document.body.classList.add('logs-fullscreen-active');
lockScroll();
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (document.querySelector('.modal-overlay')) return;
setFullscreenLogs(false);
};
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.classList.remove('logs-fullscreen-active');
unlockScroll();
};
}, [fullscreenLogs]);
return (
<div className={styles.container}>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.runtimeNotice}>
{t(`logs.runtime_${serverRuntimeKind}`)}
</div>
<div className={styles.runtimeNotice}>{t(`logs.runtime_${serverRuntimeKind}`)}</div>
</div>
<div className={styles.tabBar}>
@@ -579,7 +628,10 @@ export function LogsPage() {
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'errors' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('errors')}
onClick={() => {
setFullscreenLogs(false);
setActiveTab('errors');
}}
>
{t('logs.error_logs_modal_title')}
</button>
@@ -587,7 +639,11 @@ export function LogsPage() {
<div className={styles.content}>
{activeTab === 'logs' && (
<Card className={styles.logCard}>
<Card
className={[styles.logCard, fullscreenLogs ? styles.logCardFullscreen : '']
.filter(Boolean)
.join(' ')}
>
{showFileLoggingRequired && (
<div className="status-badge warning">
{t(
@@ -600,63 +656,67 @@ export function LogsPage() {
{error && <div className="error-box">{error}</div>}
<div className={styles.filters}>
<div className={styles.searchWrapper}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('logs.search_placeholder')}
className={styles.searchInput}
rightElement={
searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title="Clear"
aria-label="Clear"
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
{!fullscreenLogs && (
<>
<div className={styles.searchWrapper}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('logs.search_placeholder')}
className={styles.searchInput}
rightElement={
searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title="Clear"
aria-label="Clear"
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
<div className={styles.filterPanelHeader}>
<Button
type="button"
variant="secondary"
size="sm"
className={styles.filterPanelToggle}
onClick={() => setStructuredFiltersExpanded((prev) => !prev)}
aria-expanded={structuredFiltersExpanded}
aria-controls={structuredFiltersPanelId}
title={
structuredFiltersExpanded
? t('logs.filter_panel_collapse')
: t('logs.filter_panel_expand')
}
>
<span className={styles.filterPanelButtonContent}>
<IconSlidersHorizontal size={16} />
<span>{t('logs.filter_panel_title')}</span>
{structuredFilterCount > 0 && (
<span className={styles.filterPanelCount}>
{t('logs.filter_panel_active_count', { count: structuredFilterCount })}
<div className={styles.filterPanelHeader}>
<Button
type="button"
variant="secondary"
size="sm"
className={styles.filterPanelToggle}
onClick={() => setStructuredFiltersExpanded((prev) => !prev)}
aria-expanded={structuredFiltersExpanded}
aria-controls={structuredFiltersPanelId}
title={
structuredFiltersExpanded
? t('logs.filter_panel_collapse')
: t('logs.filter_panel_expand')
}
>
<span className={styles.filterPanelButtonContent}>
<IconSlidersHorizontal size={16} />
<span>{t('logs.filter_panel_title')}</span>
{structuredFilterCount > 0 && (
<span className={styles.filterPanelCount}>
{t('logs.filter_panel_active_count', { count: structuredFilterCount })}
</span>
)}
{structuredFiltersExpanded ? (
<IconChevronUp size={16} />
) : (
<IconChevronDown size={16} />
)}
</span>
)}
{structuredFiltersExpanded ? (
<IconChevronUp size={16} />
) : (
<IconChevronDown size={16} />
)}
</span>
</Button>
</div>
</Button>
</div>
</>
)}
{structuredFiltersExpanded && (
{!fullscreenLogs && structuredFiltersExpanded && (
<div id={structuredFiltersPanelId} className={styles.structuredFilters}>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_method')}</span>
@@ -813,6 +873,23 @@ export function LogsPage() {
{t('logs.clear_button')}
</span>
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setFullscreenLogs((prev) => !prev)}
className={styles.actionButton}
aria-pressed={fullscreenLogs}
title={
fullscreenLogs ? t('logs.exit_fullscreen_button') : t('logs.fullscreen_button')
}
>
<span className={styles.buttonContent}>
{fullscreenLogs ? <IconMinimize2 size={16} /> : <IconMaximize2 size={16} />}
{fullscreenLogs
? t('logs.exit_fullscreen_button')
: t('logs.fullscreen_button')}
</span>
</Button>
</div>
</div>
@@ -821,16 +898,16 @@ export function LogsPage() {
) : logState.buffer.length > 0 && filteredLines.length > 0 ? (
<div
ref={scroller.logViewerRef}
className={styles.logPanel}
className={[styles.logPanel, fullscreenLogs ? styles.logPanelFullscreen : '']
.filter(Boolean)
.join(' ')}
onScroll={scroller.handleLogScroll}
>
{scroller.canLoadMore && (
<div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span>
<div className={styles.loadMoreStats}>
<span>
{t('logs.loaded_lines', { count: filteredLines.length })}
</span>
<span>{t('logs.loaded_lines', { count: filteredLines.length })}</span>
{removedCount > 0 && (
<span className={styles.loadMoreCount}>
{t('logs.filtered_lines', { count: removedCount })}
@@ -993,7 +1070,9 @@ export function LogsPage() {
{requestLogEnabled && !isHomeRuntime && (
<div>
<div className="status-badge warning">{t('logs.error_logs_request_log_enabled')}</div>
<div className="status-badge warning">
{t('logs.error_logs_request_log_enabled')}
</div>
</div>
)}
@@ -1041,7 +1120,11 @@ export function LogsPage() {
title={t('logs.request_log_download_title')}
footer={
<>
<Button variant="secondary" onClick={closeRequestLogModal} disabled={requestLogDownloading}>
<Button
variant="secondary"
onClick={closeRequestLogModal}
disabled={requestLogDownloading}
>
{t('common.cancel')}
</Button>
<Button
+3 -3
View File
@@ -14,6 +14,7 @@ import {
} from '@/stores';
import { configApi, versionApi } from '@/services/api';
import { apiKeysApi } from '@/services/api/apiKeys';
import { formatDateTimeValue } from '@/utils/format';
import { classifyModels } from '@/utils/models';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
@@ -109,9 +110,8 @@ export function SystemPage() {
const appVersion = __APP_VERSION__ || t('system_info.version_unknown');
const apiVersion = auth.serverVersion || t('system_info.version_unknown');
const buildTime = auth.serverBuildDate
? new Date(auth.serverBuildDate).toLocaleString(i18n.language)
: t('system_info.version_unknown');
const buildTime =
formatDateTimeValue(auth.serverBuildDate, i18n.language) || t('system_info.version_unknown');
const getIconForCategory = (categoryId: string): string | null => {
const iconEntry = MODEL_CATEGORY_ICONS[categoryId];
+60 -48
View File
@@ -5,6 +5,7 @@
import { apiClient } from './client';
import type { AuthFilesResponse } from '@/types/authFile';
import type { OAuthModelAliasEntry } from '@/types';
import { normalizeOAuthProviderKey } from '@/utils/providerKeys';
import { parseTimestampMs } from '@/utils/timestamp';
type StatusError = { status?: number };
@@ -270,9 +271,7 @@ const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]
const result: Record<string, string[]> = {};
Object.entries(source as Record<string, unknown>).forEach(([provider, models]) => {
const key = String(provider ?? '')
.trim()
.toLowerCase();
const key = normalizeOAuthProviderKey(String(provider ?? ''));
if (!key) return;
const rawList = Array.isArray(models)
@@ -281,8 +280,8 @@ const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]
? models.split(/[\n,]+/)
: [];
const seen = new Set<string>();
const normalized: string[] = [];
const normalized = result[key] ?? [];
const seen = new Set(normalized.map((item) => item.toLowerCase()));
rawList.forEach((item) => {
const trimmed = String(item ?? '').trim();
if (!trimmed) return;
@@ -302,40 +301,36 @@ const normalizeOauthModelAlias = (payload: unknown): Record<string, OAuthModelAl
if (!payload || typeof payload !== 'object') return {};
const record = payload as Record<string, unknown>;
const source =
record['oauth-model-alias'] ??
record.items ??
payload;
const source = record['oauth-model-alias'] ?? record.items ?? payload;
if (!source || typeof source !== 'object') return {};
const result: Record<string, OAuthModelAliasEntry[]> = {};
Object.entries(source as Record<string, unknown>).forEach(([channel, mappings]) => {
const key = String(channel ?? '')
.trim()
.toLowerCase();
const key = normalizeOAuthProviderKey(String(channel ?? ''));
if (!key) return;
if (!Array.isArray(mappings)) return;
const seen = new Set<string>();
const normalized = mappings
.map((item) => {
if (!item || typeof item !== 'object') return null;
const entry = item as Record<string, unknown>;
const name = String(entry.name ?? entry.id ?? entry.model ?? '').trim();
const alias = String(entry.alias ?? '').trim();
if (!name || !alias) return null;
const fork = entry.fork === true;
return fork ? { name, alias, fork } : { name, alias };
})
const normalized = result[key] ?? [];
const seenAlias = new Set(normalized.map((entry) => entry.alias.toLowerCase()));
mappings
.map((item) => {
if (!item || typeof item !== 'object') return null;
const entry = item as Record<string, unknown>;
const name = String(entry.name ?? entry.id ?? entry.model ?? '').trim();
const alias = String(entry.alias ?? '').trim();
if (!name || !alias) return null;
const fork = entry.fork === true;
return fork ? { name, alias, fork } : { name, alias };
})
.filter(Boolean)
.filter((entry) => {
.forEach((entry) => {
const aliasEntry = entry as OAuthModelAliasEntry;
const dedupeKey = `${aliasEntry.name.toLowerCase()}::${aliasEntry.alias.toLowerCase()}::${aliasEntry.fork ? '1' : '0'}`;
if (seen.has(dedupeKey)) return false;
seen.add(dedupeKey);
return true;
}) as OAuthModelAliasEntry[];
const aliasKey = aliasEntry.alias.toLowerCase();
if (seenAlias.has(aliasKey)) return;
seenAlias.add(aliasKey);
normalized.push(aliasEntry);
});
if (normalized.length) {
result[key] = normalized;
@@ -389,9 +384,12 @@ export const authFilesApi = {
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),
downloadText: async (name: string): Promise<string> => {
const response = await apiClient.getRaw(`/auth-files/download?name=${encodeURIComponent(name)}`, {
responseType: 'blob'
});
const response = await apiClient.getRaw(
`/auth-files/download?name=${encodeURIComponent(name)}`,
{
responseType: 'blob',
}
);
const blob = response.data as Blob;
return blob.text();
},
@@ -413,10 +411,15 @@ export const authFilesApi = {
},
saveOauthExcludedModels: (provider: string, models: string[]) =>
apiClient.patch('/oauth-excluded-models', { provider, models }),
apiClient.patch('/oauth-excluded-models', {
provider: normalizeOAuthProviderKey(provider),
models,
}),
deleteOauthExcludedEntry: (provider: string) =>
apiClient.delete(`/oauth-excluded-models?provider=${encodeURIComponent(provider)}`),
apiClient.delete(
`/oauth-excluded-models?provider=${encodeURIComponent(normalizeOAuthProviderKey(provider))}`
),
replaceOauthExcludedModels: (map: Record<string, string[]>) =>
apiClient.put('/oauth-excluded-models', normalizeOauthExcludedModels(map)),
@@ -428,29 +431,36 @@ export const authFilesApi = {
},
saveOauthModelAlias: async (channel: string, aliases: OAuthModelAliasEntry[]) => {
const normalizedChannel = String(channel ?? '')
.trim()
.toLowerCase();
const normalizedAliases = normalizeOauthModelAlias({ [normalizedChannel]: aliases })[normalizedChannel] ?? [];
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, { channel: normalizedChannel, aliases: normalizedAliases });
const normalizedChannel = normalizeOAuthProviderKey(String(channel ?? ''));
const normalizedAliases =
normalizeOauthModelAlias({ [normalizedChannel]: aliases })[normalizedChannel] ?? [];
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, {
channel: normalizedChannel,
aliases: normalizedAliases,
});
},
deleteOauthModelAlias: async (channel: string) => {
const normalizedChannel = String(channel ?? '')
.trim()
.toLowerCase();
const normalizedChannel = normalizeOAuthProviderKey(String(channel ?? ''));
try {
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, { channel: normalizedChannel, aliases: [] });
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, {
channel: normalizedChannel,
aliases: [],
});
} catch (err: unknown) {
const status = getStatusCode(err);
if (status !== 405) throw err;
await apiClient.delete(`${OAUTH_MODEL_ALIAS_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
await apiClient.delete(
`${OAUTH_MODEL_ALIAS_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`
);
}
},
// 获取认证凭证支持的模型
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
async getModelsForAuthFile(
name: string
): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const data = await apiClient.get<Record<string, unknown>>(
`/auth-files/models?name=${encodeURIComponent(name)}`
);
@@ -461,8 +471,10 @@ export const authFilesApi = {
},
// 获取指定 channel 的模型定义
async getModelDefinitions(channel: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const normalizedChannel = String(channel ?? '').trim().toLowerCase();
async getModelDefinitions(
channel: string
): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const normalizedChannel = normalizeOAuthProviderKey(String(channel ?? ''));
if (!normalizedChannel) return [];
const data = await apiClient.get<Record<string, unknown>>(
`/model-definitions/${encodeURIComponent(normalizedChannel)}`
@@ -471,5 +483,5 @@ export const authFilesApi = {
return Array.isArray(models)
? (models as { id: string; display_name?: string; type?: string; owned_by?: string }[])
: [];
}
},
};
+22 -10
View File
@@ -115,7 +115,8 @@ const findRawRecord = (
usedIndexes: Set<number>,
payload: Record<string, unknown>,
index: number,
getIdentity: (record: Record<string, unknown>) => string
getIdentity: (record: Record<string, unknown>) => string,
fallbackByIndex = true
) => {
const identity = getIdentity(payload);
if (identity) {
@@ -129,10 +130,12 @@ const findRawRecord = (
}
}
const fallback = rawRecords[index];
if (fallback && !usedIndexes.has(index)) {
usedIndexes.add(index);
return fallback;
if (fallbackByIndex) {
const fallback = rawRecords[index];
if (fallback && !usedIndexes.has(index)) {
usedIndexes.add(index);
return fallback;
}
}
return undefined;
@@ -142,7 +145,8 @@ const mergeKnownRecordList = (
rawItems: unknown,
payloadItems: Record<string, unknown>[],
knownFields: readonly string[],
getIdentity: (record: Record<string, unknown>) => string
getIdentity: (record: Record<string, unknown>) => string,
fallbackByIndex = true
) => {
const rawRecords = Array.isArray(rawItems)
? rawItems.map((item) => (isRecord(item) ? item : undefined))
@@ -150,7 +154,14 @@ const mergeKnownRecordList = (
const usedIndexes = new Set<number>();
return payloadItems.map((payload, index) => {
const raw = findRawRecord(rawRecords, usedIndexes, payload, index, getIdentity);
const raw = findRawRecord(
rawRecords,
usedIndexes,
payload,
index,
getIdentity,
fallbackByIndex
);
return mergeKnownFields(raw, payload, knownFields);
});
};
@@ -167,7 +178,8 @@ const mergeModelPayloads = (raw: unknown, models: unknown) =>
isRecord(raw) ? raw.models : undefined,
models.filter(isRecord),
MODEL_ALIAS_FIELDS,
modelIdentity
modelIdentity,
false
)
: undefined;
@@ -490,6 +502,6 @@ export const providersApi = {
updateOpenAIProviderDisabled: (index: number, disabled: boolean) =>
apiClient.patch('/openai-compatibility', { index, value: { disabled } }),
deleteOpenAIProvider: (name: string) =>
apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`),
deleteOpenAIProvider: (index: number) =>
apiClient.delete(`/openai-compatibility?index=${encodeURIComponent(String(index))}`),
};
+23
View File
@@ -67,3 +67,26 @@ export function formatUnixTimestamp(value: unknown, locale?: string): string {
if (Number.isNaN(date.getTime())) return '';
return locale ? date.toLocaleString(locale) : date.toLocaleString();
}
export function parseDateValue(value: unknown): Date | null {
if (value === null || value === undefined || value === '') return null;
const date =
typeof value === 'number'
? new Date(value < 1e12 ? value * 1000 : value)
: (parseTimestamp(value) ?? new Date(String(value)));
return Number.isNaN(date.getTime()) ? null : date;
}
export function formatDateValue(value: unknown, locale?: string): string {
const date = parseDateValue(value);
if (!date) return '';
return locale ? date.toLocaleDateString(locale) : date.toLocaleDateString();
}
export function formatDateTimeValue(value: unknown, locale?: string): string {
const date = parseDateValue(value);
if (!date) return '';
return locale ? date.toLocaleString(locale) : date.toLocaleString();
}
+12
View File
@@ -0,0 +1,12 @@
const OAUTH_PROVIDER_ALIASES: Record<string, string> = {
'anti-gravity': 'antigravity',
gemini: 'gemini-cli',
grok: 'xai',
'x-ai': 'xai',
'x.ai': 'xai',
};
export const normalizeOAuthProviderKey = (value: string): string => {
const key = value.trim().toLowerCase().replace(/_/g, '-');
return OAUTH_PROVIDER_ALIASES[key] ?? key;
};