mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
5 Commits
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
+8
-8
@@ -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"
|
||||
/>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Журналы ошибок запросов",
|
||||
|
||||
@@ -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": "错误请求日志",
|
||||
|
||||
@@ -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": "錯誤請求記錄",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 }[])
|
||||
: [];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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))}`),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user