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
8 Commits
@@ -213,7 +213,6 @@ export function MainLayout() {
|
||||
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
@@ -431,16 +430,12 @@ export function MainLayout() {
|
||||
metaKey: 'nav_meta.quota_management',
|
||||
icon: sidebarIcons.quota,
|
||||
},
|
||||
...(config?.loggingToFile
|
||||
? [
|
||||
{
|
||||
path: '/logs',
|
||||
labelKey: 'nav.logs',
|
||||
metaKey: 'nav_meta.logs',
|
||||
icon: sidebarIcons.logs,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
path: '/logs',
|
||||
labelKey: 'nav.logs',
|
||||
metaKey: 'nav_meta.logs',
|
||||
icon: sidebarIcons.logs,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
@@ -417,6 +460,7 @@ export function ProvidersWorkbenchPage() {
|
||||
workbench={workbench}
|
||||
onCreated={handleCreated}
|
||||
onUpdated={handleUpdated}
|
||||
mutationDisabled={disableMutations}
|
||||
usageByProvider={usageByProvider}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -36,6 +36,7 @@ interface ProviderSheetProps {
|
||||
workbench: UseProviderWorkbenchResult;
|
||||
onCreated: () => void;
|
||||
onUpdated: () => void;
|
||||
mutationDisabled?: boolean;
|
||||
usageByProvider?: ProviderRecentUsageMap;
|
||||
ref?: Ref<ProviderSheetHandle>;
|
||||
}
|
||||
@@ -47,6 +48,7 @@ export function ProviderSheet({
|
||||
workbench,
|
||||
onCreated,
|
||||
onUpdated,
|
||||
mutationDisabled = false,
|
||||
usageByProvider,
|
||||
ref,
|
||||
}: ProviderSheetProps) {
|
||||
@@ -70,6 +72,8 @@ export function ProviderSheet({
|
||||
const descriptor = PROVIDER_DESCRIPTORS[state.brand];
|
||||
const isAmpcode = state.brand === 'ampcode';
|
||||
const isEditingForm = state.mode === 'create' || state.mode === 'edit';
|
||||
const formMutating = submitting || mutationDisabled;
|
||||
const submitDisabled = formMutating || (state.mode === 'edit' && !isDirty);
|
||||
|
||||
const confirmDiscardIfDirty = useCallback((): Promise<boolean> => {
|
||||
if (!isEditingForm || !isDirty || submitting) {
|
||||
@@ -111,6 +115,7 @@ export function ProviderSheet({
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (input: ProviderEntryFormInput) => {
|
||||
if (mutationDisabled) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await workbench.createProvider(state.brand, input);
|
||||
@@ -119,12 +124,12 @@ export function ProviderSheet({
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[onCreated, state.brand, workbench]
|
||||
[mutationDisabled, onCreated, state.brand, workbench]
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
async (input: ProviderEntryFormInput) => {
|
||||
if (!state.resource) return;
|
||||
if (!state.resource || mutationDisabled || !isDirty) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await workbench.updateProvider(state.resource, input);
|
||||
@@ -133,11 +138,12 @@ export function ProviderSheet({
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[onUpdated, state.resource, workbench]
|
||||
[isDirty, mutationDisabled, onUpdated, state.resource, workbench]
|
||||
);
|
||||
|
||||
const handleAmpcodeSubmit = useCallback(
|
||||
async (config: Parameters<UseProviderWorkbenchResult['saveAmpcode']>[0]) => {
|
||||
if (mutationDisabled || !isDirty) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await workbench.saveAmpcode(config);
|
||||
@@ -146,7 +152,7 @@ export function ProviderSheet({
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[onUpdated, workbench]
|
||||
[isDirty, mutationDisabled, onUpdated, workbench]
|
||||
);
|
||||
|
||||
const renderBody = () => {
|
||||
@@ -162,7 +168,7 @@ export function ProviderSheet({
|
||||
<AmpcodeForm
|
||||
key={formKey}
|
||||
resource={state.resource}
|
||||
mutating={submitting || workbench.mutating}
|
||||
mutating={formMutating}
|
||||
formId={formId}
|
||||
onSubmit={handleAmpcodeSubmit}
|
||||
onDirtyChange={handleDirtyChange}
|
||||
@@ -175,7 +181,7 @@ export function ProviderSheet({
|
||||
brand={state.brand as Exclude<ProviderBrand, 'ampcode'>}
|
||||
resource={state.resource}
|
||||
mode={state.mode}
|
||||
mutating={submitting || workbench.mutating}
|
||||
mutating={formMutating}
|
||||
formId={formId}
|
||||
onSubmit={state.mode === 'create' ? handleCreate : handleUpdate}
|
||||
onDirtyChange={handleDirtyChange}
|
||||
@@ -198,6 +204,7 @@ export function ProviderSheet({
|
||||
type="button"
|
||||
className={`${styles.footerBtn} ${styles.footerBtnPrimary}`}
|
||||
onClick={onSwitchToEdit}
|
||||
disabled={formMutating}
|
||||
>
|
||||
<IconPencil size={14} />
|
||||
{t('providersPage.actions.edit')}
|
||||
@@ -226,7 +233,7 @@ export function ProviderSheet({
|
||||
type="submit"
|
||||
form={formId}
|
||||
className={`${styles.footerBtn} ${styles.footerBtnPrimary}`}
|
||||
disabled={submitting}
|
||||
disabled={submitDisabled}
|
||||
>
|
||||
{submitting ? (
|
||||
<IconLoader2 size={14} />
|
||||
|
||||
@@ -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",
|
||||
@@ -730,12 +731,18 @@
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logs Viewer",
|
||||
"runtime_unknown": "Current runtime: detecting",
|
||||
"runtime_cpa": "Current runtime: CPA",
|
||||
"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",
|
||||
"error_logs_description": "Pick an error request log file to download (only generated when request logging is off).",
|
||||
"error_logs_home_unavailable": "In Home mode, use Log Content for database logs. Error request log files only apply to CPA file logs.",
|
||||
"error_logs_request_log_enabled": "Request logging is enabled, so this list will always be empty. Disable request logging and refresh to view error logs.",
|
||||
"error_logs_empty": "No error request log files found",
|
||||
"error_logs_load_error": "Failed to load error log list",
|
||||
@@ -748,6 +755,13 @@
|
||||
"request_log_download_success": "Request log downloaded successfully",
|
||||
"empty_title": "No Logs Available",
|
||||
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
|
||||
"cpa_file_logging_required": "The connected runtime is CPA. Log viewing requires logging to file to be enabled first.",
|
||||
"cpa_file_logging_required_title": "File logging is required",
|
||||
"cpa_file_logging_required_desc": "CPA reads this log view from local log files. Enable logging-to-file in the config, then refresh.",
|
||||
"file_logging_required": "This logs endpoint requires logging to file before file logs can be read.",
|
||||
"file_logging_required_title": "File logging is required",
|
||||
"file_logging_required_desc": "This logs endpoint reads local log files. Enable logging-to-file in the config, then refresh.",
|
||||
"home_clear_unavailable": "Home logs come from database queries and cannot be cleared here.",
|
||||
"log_content": "Log Content",
|
||||
"loading": "Loading logs...",
|
||||
"load_error": "Failed to load 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": "Сохранить/обновить",
|
||||
@@ -727,12 +728,18 @@
|
||||
},
|
||||
"logs": {
|
||||
"title": "Просмотр журналов",
|
||||
"runtime_unknown": "Текущий режим: определяется",
|
||||
"runtime_cpa": "Текущий режим: CPA",
|
||||
"runtime_home": "Текущий режим: Home",
|
||||
"refresh_button": "Обновить журналы",
|
||||
"clear_button": "Очистить журналы",
|
||||
"fullscreen_button": "На весь экран",
|
||||
"exit_fullscreen_button": "Выйти из полноэкранного режима",
|
||||
"download_button": "Скачать журналы",
|
||||
"error_log_button": "Выбрать журнал ошибок",
|
||||
"error_logs_modal_title": "Журналы ошибок запросов",
|
||||
"error_logs_description": "Выберите файл журнала ошибок запроса для скачивания (создаётся только при отключённом журналировании запросов).",
|
||||
"error_logs_home_unavailable": "В режиме Home смотрите журналы базы данных на вкладке содержимого. Файлы ошибок запросов относятся только к файловым журналам CPA.",
|
||||
"error_logs_request_log_enabled": "Журналирование запросов включено, поэтому этот список всегда будет пустым. Отключите журналирование запросов и обновите список, чтобы просмотреть журналы ошибок.",
|
||||
"error_logs_empty": "Файлы журнала ошибок запросов не найдены",
|
||||
"error_logs_load_error": "Не удалось загрузить список журналов ошибок",
|
||||
@@ -745,6 +752,13 @@
|
||||
"request_log_download_success": "Журнал запросов успешно скачан",
|
||||
"empty_title": "Журналы недоступны",
|
||||
"empty_desc": "Когда включена опция \"Включить журналирование в файл\", журналы появятся здесь",
|
||||
"cpa_file_logging_required": "Подключён режим CPA. Для просмотра журналов сначала включите запись журналов в файл.",
|
||||
"cpa_file_logging_required_title": "Требуется файловое журналирование",
|
||||
"cpa_file_logging_required_desc": "CPA читает этот журнал из локальных файлов. Включите logging-to-file в конфигурации и обновите страницу.",
|
||||
"file_logging_required": "Этот endpoint журналов требует включить запись журналов в файл перед чтением файловых журналов.",
|
||||
"file_logging_required_title": "Требуется файловое журналирование",
|
||||
"file_logging_required_desc": "Этот endpoint журналов читает локальные файлы. Включите logging-to-file в конфигурации и обновите страницу.",
|
||||
"home_clear_unavailable": "Журналы Home загружаются из базы данных и не могут быть очищены здесь.",
|
||||
"log_content": "Содержимое журнала",
|
||||
"loading": "Загрузка журналов...",
|
||||
"load_error": "Не удалось загрузить журналы",
|
||||
|
||||
@@ -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": "保存/更新",
|
||||
@@ -730,12 +731,18 @@
|
||||
},
|
||||
"logs": {
|
||||
"title": "日志查看",
|
||||
"runtime_unknown": "当前运行端:识别中",
|
||||
"runtime_cpa": "当前运行端:CPA",
|
||||
"runtime_home": "当前运行端:Home",
|
||||
"refresh_button": "刷新日志",
|
||||
"clear_button": "清空日志",
|
||||
"fullscreen_button": "全屏显示",
|
||||
"exit_fullscreen_button": "退出全屏",
|
||||
"download_button": "下载日志",
|
||||
"error_log_button": "选择错误日志",
|
||||
"error_logs_modal_title": "错误请求日志",
|
||||
"error_logs_description": "请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。",
|
||||
"error_logs_home_unavailable": "Home 模式下请在日志内容页查看数据库日志,错误请求日志列表仅适用于 CPA 文件日志。",
|
||||
"error_logs_request_log_enabled": "当前已开启请求日志,按接口约定错误请求日志列表会始终为空。关闭请求日志后再刷新即可查看。",
|
||||
"error_logs_empty": "暂无错误请求日志文件",
|
||||
"error_logs_load_error": "加载错误日志列表失败",
|
||||
@@ -748,6 +755,13 @@
|
||||
"request_log_download_success": "报文下载成功",
|
||||
"empty_title": "暂无日志记录",
|
||||
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
|
||||
"cpa_file_logging_required": "当前连接的是 CPA,日志查看需要先开启“日志记录到文件”。",
|
||||
"cpa_file_logging_required_title": "需要开启文件日志",
|
||||
"cpa_file_logging_required_desc": "CPA 的日志接口读取本地日志文件。请在配置中开启 logging-to-file 后再刷新查看。",
|
||||
"file_logging_required": "当前日志接口需要先开启“日志记录到文件”后才能读取文件日志。",
|
||||
"file_logging_required_title": "需要开启文件日志",
|
||||
"file_logging_required_desc": "当前日志接口读取本地日志文件。请在配置中开启 logging-to-file 后再刷新查看。",
|
||||
"home_clear_unavailable": "Home 日志来自数据库查询,当前不支持从这里清空。",
|
||||
"log_content": "日志内容",
|
||||
"loading": "正在加载日志...",
|
||||
"load_error": "加载日志失败",
|
||||
|
||||
@@ -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": "儲存/更新",
|
||||
@@ -756,12 +757,18 @@
|
||||
},
|
||||
"logs": {
|
||||
"title": "記錄檢視",
|
||||
"runtime_unknown": "目前執行端:識別中",
|
||||
"runtime_cpa": "目前執行端:CPA",
|
||||
"runtime_home": "目前執行端:Home",
|
||||
"refresh_button": "重新整理記錄",
|
||||
"clear_button": "清空記錄",
|
||||
"fullscreen_button": "全螢幕顯示",
|
||||
"exit_fullscreen_button": "退出全螢幕",
|
||||
"download_button": "下載記錄",
|
||||
"error_log_button": "選擇錯誤記錄",
|
||||
"error_logs_modal_title": "錯誤請求記錄",
|
||||
"error_logs_description": "請選擇要下載的錯誤請求記錄檔案(僅在關閉請求記錄時產生)。",
|
||||
"error_logs_home_unavailable": "Home 模式下請在記錄內容頁查看資料庫記錄,錯誤請求記錄清單僅適用於 CPA 檔案記錄。",
|
||||
"error_logs_request_log_enabled": "目前已開啟請求記錄,按介面約定錯誤請求記錄清單會始終為空。關閉請求記錄後再重新整理即可查看。",
|
||||
"error_logs_empty": "暫無錯誤請求記錄檔案",
|
||||
"error_logs_load_error": "載入錯誤記錄清單失敗",
|
||||
@@ -774,6 +781,13 @@
|
||||
"request_log_download_success": "封包下載成功",
|
||||
"empty_title": "暫無記錄",
|
||||
"empty_desc": "當啟用「記錄到檔案」功能後,記錄將顯示在這裡",
|
||||
"cpa_file_logging_required": "目前連線的是 CPA,記錄檢視需要先開啟「記錄到檔案」。",
|
||||
"cpa_file_logging_required_title": "需要開啟檔案記錄",
|
||||
"cpa_file_logging_required_desc": "CPA 的記錄介面會讀取本機記錄檔。請在設定中開啟 logging-to-file 後再重新整理查看。",
|
||||
"file_logging_required": "目前記錄介面需要先開啟「記錄到檔案」後才能讀取檔案記錄。",
|
||||
"file_logging_required_title": "需要開啟檔案記錄",
|
||||
"file_logging_required_desc": "目前記錄介面會讀取本機記錄檔。請在設定中開啟 logging-to-file 後再重新整理查看。",
|
||||
"home_clear_unavailable": "Home 記錄來自資料庫查詢,目前不支援從這裡清空。",
|
||||
"log_content": "記錄內容",
|
||||
"loading": "正在載入記錄...",
|
||||
"load_error": "載入記錄失敗",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -13,11 +13,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
margin: 0 0 $spacing-lg 0;
|
||||
|
||||
@include mobile {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-lg 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tabBar {
|
||||
@@ -84,6 +98,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
.runtimeNotice {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-full;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -522,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);
|
||||
@@ -622,6 +648,9 @@
|
||||
@media (max-height: 820px) {
|
||||
.pageTitle {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
@@ -671,6 +700,9 @@
|
||||
@media (max-height: 600px) {
|
||||
.pageTitle {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
@@ -722,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;
|
||||
}
|
||||
}
|
||||
|
||||
+315
-92
@@ -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,
|
||||
@@ -23,17 +26,13 @@ import {
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { logsApi } from '@/services/api/logs';
|
||||
import { logsApi, type LogsQuery } from '@/services/api/logs';
|
||||
import { versionApi } from '@/services/api/version';
|
||||
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';
|
||||
@@ -51,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;
|
||||
@@ -61,14 +91,44 @@ const getErrorMessage = (err: unknown): string => {
|
||||
return typeof message === 'string' ? message : '';
|
||||
};
|
||||
|
||||
const getErrorPayloadText = (err: unknown): string => {
|
||||
if (typeof err !== 'object' || err === null) return '';
|
||||
const payloads = [
|
||||
(err as { data?: unknown }).data,
|
||||
(err as { details?: unknown }).details,
|
||||
].filter((payload) => payload !== undefined);
|
||||
return payloads
|
||||
.map((payload) => {
|
||||
if (typeof payload === 'string') return payload;
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const isLoggingToFileDisabledError = (err: unknown): boolean => {
|
||||
const text = `${getErrorMessage(err)} ${getErrorPayloadText(err)}`.toLowerCase();
|
||||
return text.includes('logging to file disabled');
|
||||
};
|
||||
|
||||
type TabType = 'logs' | 'errors';
|
||||
|
||||
export function LogsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const serverRuntimeKind = useAuthStore((state) => state.serverRuntimeKind);
|
||||
const updateServerRuntimeKind = useAuthStore((state) => state.updateServerRuntimeKind);
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const requestLogEnabled = config?.requestLog ?? false;
|
||||
const loggingToFileEnabled = config?.loggingToFile ?? false;
|
||||
const cpaNeedsFileLogging = serverRuntimeKind === 'cpa' && !loggingToFileEnabled;
|
||||
const isHomeRuntime = serverRuntimeKind === 'home';
|
||||
const [fileLoggingRequired, setFileLoggingRequired] = useState(false);
|
||||
const showFileLoggingRequired = cpaNeedsFileLogging || fileLoggingRequired;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('logs');
|
||||
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
|
||||
@@ -91,8 +151,10 @@ 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>>({});
|
||||
const longPressRef = useRef<{
|
||||
timer: number | null;
|
||||
startX: number;
|
||||
@@ -102,10 +164,13 @@ export function LogsPage() {
|
||||
const logRequestInFlightRef = useRef(false);
|
||||
const pendingFullReloadRef = useRef(false);
|
||||
|
||||
// 保存最新时间戳用于增量获取
|
||||
const latestTimestampRef = useRef<number>(0);
|
||||
// 保存最新游标用于增量获取
|
||||
const latestCursorRef = useRef<LogsQuery['after']>(undefined);
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
const refreshDisabled = disableControls || loading || cpaNeedsFileLogging;
|
||||
const autoRefreshDisabled = disableControls || showFileLoggingRequired;
|
||||
const clearDisabled = disableControls || showFileLoggingRequired || isHomeRuntime;
|
||||
|
||||
const loadLogs = async (incremental = false) => {
|
||||
if (connectionStatus !== 'connected') {
|
||||
@@ -113,6 +178,18 @@ export function LogsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cpaNeedsFileLogging) {
|
||||
if (!incremental) {
|
||||
latestCursorRef.current = undefined;
|
||||
requestLogHomeIpByIdRef.current = {};
|
||||
setFileLoggingRequired(false);
|
||||
setLogState({ buffer: [], visibleFrom: 0 });
|
||||
setError('');
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (logRequestInFlightRef.current) {
|
||||
if (!incremental) {
|
||||
pendingFullReloadRef.current = true;
|
||||
@@ -135,13 +212,25 @@ export function LogsPage() {
|
||||
scrollerInstance?.requestScrollToBottom();
|
||||
}
|
||||
|
||||
const params =
|
||||
incremental && latestTimestampRef.current > 0 ? { after: latestTimestampRef.current } : {};
|
||||
const params: LogsQuery =
|
||||
incremental && latestCursorRef.current
|
||||
? { after: getIncrementalAfter(latestCursorRef.current), limit: MAX_BUFFER_LINES }
|
||||
: { limit: MAX_BUFFER_LINES };
|
||||
const data = await logsApi.fetchLogs(params);
|
||||
setFileLoggingRequired(false);
|
||||
|
||||
// 更新时间戳
|
||||
if (data['latest-timestamp']) {
|
||||
latestTimestampRef.current = data['latest-timestamp'];
|
||||
// 更新游标
|
||||
if (data.latestCursor) {
|
||||
latestCursorRef.current = data.latestCursor;
|
||||
} else if (!incremental) {
|
||||
latestCursorRef.current = undefined;
|
||||
}
|
||||
if (data.requestLogHomeIpById) {
|
||||
requestLogHomeIpByIdRef.current = incremental
|
||||
? { ...requestLogHomeIpByIdRef.current, ...data.requestLogHomeIpById }
|
||||
: data.requestLogHomeIpById;
|
||||
} else if (!incremental) {
|
||||
requestLogHomeIpByIdRef.current = {};
|
||||
}
|
||||
|
||||
const newLines = Array.isArray(data.lines) ? data.lines : [];
|
||||
@@ -150,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);
|
||||
@@ -170,6 +259,16 @@ export function LogsPage() {
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to load logs:', err);
|
||||
if (isLoggingToFileDisabledError(err)) {
|
||||
if (!incremental) {
|
||||
latestCursorRef.current = undefined;
|
||||
requestLogHomeIpByIdRef.current = {};
|
||||
setFileLoggingRequired(true);
|
||||
setLogState({ buffer: [], visibleFrom: 0 });
|
||||
setError('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!incremental) {
|
||||
setError(getErrorMessage(err) || t('logs.load_error'));
|
||||
}
|
||||
@@ -188,6 +287,18 @@ export function LogsPage() {
|
||||
useHeaderRefresh(() => loadLogs(false));
|
||||
|
||||
const clearLogs = async () => {
|
||||
if (isHomeRuntime) {
|
||||
showNotification(t('logs.home_clear_unavailable'), 'warning');
|
||||
return;
|
||||
}
|
||||
if (cpaNeedsFileLogging) {
|
||||
showNotification(t('logs.cpa_file_logging_required'), 'warning');
|
||||
return;
|
||||
}
|
||||
if (fileLoggingRequired) {
|
||||
showNotification(t('logs.file_logging_required'), 'warning');
|
||||
return;
|
||||
}
|
||||
showConfirmation({
|
||||
title: t('logs.clear_confirm_title', { defaultValue: 'Clear Logs' }),
|
||||
message: t('logs.clear_confirm'),
|
||||
@@ -197,7 +308,9 @@ export function LogsPage() {
|
||||
try {
|
||||
await logsApi.clearLogs();
|
||||
setLogState({ buffer: [], visibleFrom: 0 });
|
||||
latestTimestampRef.current = 0;
|
||||
latestCursorRef.current = undefined;
|
||||
requestLogHomeIpByIdRef.current = {};
|
||||
setFileLoggingRequired(false);
|
||||
showNotification(t('logs.clear_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
@@ -221,6 +334,12 @@ export function LogsPage() {
|
||||
setLoadingErrors(false);
|
||||
return;
|
||||
}
|
||||
if (isHomeRuntime) {
|
||||
setLoadingErrors(false);
|
||||
setErrorLogs([]);
|
||||
setErrorLogsError('');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingErrors(true);
|
||||
setErrorLogsError('');
|
||||
@@ -256,11 +375,28 @@ export function LogsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionStatus === 'connected') {
|
||||
latestTimestampRef.current = 0;
|
||||
latestCursorRef.current = undefined;
|
||||
requestLogHomeIpByIdRef.current = {};
|
||||
setFileLoggingRequired(false);
|
||||
loadLogs(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [connectionStatus]);
|
||||
}, [connectionStatus, loggingToFileEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionStatus !== 'connected' || serverRuntimeKind !== 'unknown') return;
|
||||
let cancelled = false;
|
||||
const detectRuntime = async () => {
|
||||
const runtimeKind = await versionApi.detectRuntimeKind();
|
||||
if (!cancelled && (runtimeKind === 'cpa' || runtimeKind === 'home')) {
|
||||
updateServerRuntimeKind(runtimeKind);
|
||||
}
|
||||
};
|
||||
void detectRuntime();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connectionStatus, serverRuntimeKind, updateServerRuntimeKind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'errors') return;
|
||||
@@ -270,7 +406,7 @@ export function LogsPage() {
|
||||
}, [activeTab, connectionStatus, requestLogEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || connectionStatus !== 'connected') {
|
||||
if (!autoRefresh || connectionStatus !== 'connected' || showFileLoggingRequired) {
|
||||
return;
|
||||
}
|
||||
const id = window.setInterval(() => {
|
||||
@@ -278,7 +414,7 @@ export function LogsPage() {
|
||||
}, 8000);
|
||||
return () => window.clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoRefresh, connectionStatus]);
|
||||
}, [autoRefresh, connectionStatus, showFileLoggingRequired]);
|
||||
|
||||
const visibleLines = useMemo(
|
||||
() => logState.buffer.slice(logState.visibleFrom),
|
||||
@@ -336,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(
|
||||
@@ -360,7 +496,7 @@ export function LogsPage() {
|
||||
isSearching,
|
||||
filteredLineCount: filteredLines.length,
|
||||
hasStructuredFilters: filters.hasStructuredFilters,
|
||||
showRawLogs
|
||||
showRawLogs,
|
||||
});
|
||||
|
||||
logScrollerRef.current = scroller;
|
||||
@@ -423,10 +559,13 @@ export function LogsPage() {
|
||||
const downloadRequestLog = async (id: string) => {
|
||||
setRequestLogDownloading(true);
|
||||
try {
|
||||
const response = await logsApi.downloadRequestLogById(id);
|
||||
const response = await logsApi.downloadRequestLogById(
|
||||
id,
|
||||
requestLogHomeIpByIdRef.current[id]
|
||||
);
|
||||
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);
|
||||
@@ -450,9 +589,33 @@ 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}>
|
||||
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
|
||||
<div className={styles.pageHeader}>
|
||||
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
|
||||
<div className={styles.runtimeNotice}>{t(`logs.runtime_${serverRuntimeKind}`)}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.tabBar}>
|
||||
<button
|
||||
@@ -465,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>
|
||||
@@ -473,67 +639,84 @@ 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(
|
||||
cpaNeedsFileLogging
|
||||
? 'logs.cpa_file_logging_required'
|
||||
: 'logs.file_logging_required'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
@@ -647,7 +830,7 @@ export function LogsPage() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => loadLogs(false)}
|
||||
disabled={disableControls || loading}
|
||||
disabled={refreshDisabled}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<span className={styles.buttonContent}>
|
||||
@@ -658,7 +841,7 @@ export function LogsPage() {
|
||||
<ToggleSwitch
|
||||
checked={autoRefresh}
|
||||
onChange={(value) => setAutoRefresh(value)}
|
||||
disabled={disableControls}
|
||||
disabled={autoRefreshDisabled}
|
||||
label={
|
||||
<span className={styles.switchLabel}>
|
||||
<IconTimer size={16} />
|
||||
@@ -682,7 +865,7 @@ export function LogsPage() {
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={clearLogs}
|
||||
disabled={disableControls}
|
||||
disabled={clearDisabled}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<span className={styles.buttonContent}>
|
||||
@@ -690,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>
|
||||
|
||||
@@ -698,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 })}
|
||||
@@ -828,6 +1028,19 @@ export function LogsPage() {
|
||||
title={t('logs.search_empty_title')}
|
||||
description={t('logs.search_empty_desc')}
|
||||
/>
|
||||
) : showFileLoggingRequired ? (
|
||||
<EmptyState
|
||||
title={t(
|
||||
cpaNeedsFileLogging
|
||||
? 'logs.cpa_file_logging_required_title'
|
||||
: 'logs.file_logging_required_title'
|
||||
)}
|
||||
description={t(
|
||||
cpaNeedsFileLogging
|
||||
? 'logs.cpa_file_logging_required_desc'
|
||||
: 'logs.file_logging_required_desc'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
|
||||
)}
|
||||
@@ -851,9 +1064,15 @@ export function LogsPage() {
|
||||
<div className="stack">
|
||||
<div className="hint">{t('logs.error_logs_description')}</div>
|
||||
|
||||
{requestLogEnabled && (
|
||||
{isHomeRuntime && (
|
||||
<div className="status-badge warning">{t('logs.error_logs_home_unavailable')}</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -901,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];
|
||||
|
||||
@@ -10,6 +10,7 @@ const LOG_LATENCY_REGEX =
|
||||
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
|
||||
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
|
||||
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
|
||||
const LOG_NAMED_REQUEST_ID_REGEX = /\brequest[_-]?id=([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\b/i;
|
||||
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
|
||||
const GIN_TIMESTAMP_SEGMENT_REGEX =
|
||||
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
|
||||
@@ -87,6 +88,14 @@ const inferLogLevel = (line: string): LogLevel | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractNamedRequestId = (text: string): string | undefined => {
|
||||
const match = text.match(LOG_NAMED_REQUEST_ID_REGEX);
|
||||
if (!match) return undefined;
|
||||
const id = match[1];
|
||||
if (/^-+$/.test(id)) return undefined;
|
||||
return id;
|
||||
};
|
||||
|
||||
const extractHttpMethodAndPath = (text: string): { method?: HttpMethod; path?: string } => {
|
||||
const match = text.match(HTTP_METHOD_REGEX);
|
||||
if (!match) return {};
|
||||
@@ -163,16 +172,24 @@ export const parseLogLine = (raw: string): ParsedLogLine => {
|
||||
}
|
||||
}
|
||||
|
||||
// request id (8-char hex or dashes)
|
||||
const requestIdIndex = segments.findIndex((segment) => LOG_REQUEST_ID_REGEX.test(segment));
|
||||
// request id
|
||||
const requestIdIndex = segments.findIndex(
|
||||
(segment) => LOG_REQUEST_ID_REGEX.test(segment) || Boolean(extractNamedRequestId(segment))
|
||||
);
|
||||
if (requestIdIndex >= 0) {
|
||||
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
|
||||
if (match) {
|
||||
const id = match[1];
|
||||
if (!/^-+$/.test(id)) {
|
||||
requestId = id;
|
||||
}
|
||||
const namedId = extractNamedRequestId(segments[requestIdIndex]);
|
||||
if (namedId) {
|
||||
requestId = namedId;
|
||||
consumed.add(requestIdIndex);
|
||||
} else {
|
||||
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
|
||||
if (match) {
|
||||
const id = match[1];
|
||||
if (!/^-+$/.test(id)) {
|
||||
requestId = id;
|
||||
}
|
||||
consumed.add(requestIdIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +260,10 @@ export const parseLogLine = (raw: string): ParsedLogLine => {
|
||||
const parsed = extractHttpMethodAndPath(remaining);
|
||||
method = parsed.method;
|
||||
path = parsed.path;
|
||||
|
||||
if (!requestId) {
|
||||
requestId = extractNamedRequestId(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
if (!level) level = inferLogLevel(raw);
|
||||
@@ -272,4 +293,3 @@ export const parseLogLine = (raw: string): ParsedLogLine => {
|
||||
message,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }[])
|
||||
: [];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,10 +7,15 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import type { ApiClientConfig, ApiError } from '@/types';
|
||||
import {
|
||||
BUILD_DATE_HEADER_KEYS,
|
||||
CPA_BUILD_DATE_HEADER_KEYS,
|
||||
CPA_VERSION_HEADER_KEYS,
|
||||
HOME_BUILD_DATE_HEADER_KEYS,
|
||||
HOME_VERSION_HEADER_KEYS,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
VERSION_HEADER_KEYS
|
||||
} from '@/utils/constants';
|
||||
import { computeApiUrl } from '@/utils/connection';
|
||||
import type { ServerRuntimeKind } from '@/types';
|
||||
|
||||
class ApiClient {
|
||||
private instance: AxiosInstance;
|
||||
@@ -109,14 +114,21 @@ class ApiClient {
|
||||
this.instance.interceptors.response.use(
|
||||
(response) => {
|
||||
const headers = response.headers as Record<string, string | undefined>;
|
||||
const version = this.readHeader(headers, VERSION_HEADER_KEYS);
|
||||
const buildDate = this.readHeader(headers, BUILD_DATE_HEADER_KEYS);
|
||||
const homeVersion = this.readHeader(headers, HOME_VERSION_HEADER_KEYS);
|
||||
const homeBuildDate = this.readHeader(headers, HOME_BUILD_DATE_HEADER_KEYS);
|
||||
const cpaVersion = this.readHeader(headers, CPA_VERSION_HEADER_KEYS);
|
||||
const cpaBuildDate = this.readHeader(headers, CPA_BUILD_DATE_HEADER_KEYS);
|
||||
const version = homeVersion || cpaVersion || this.readHeader(headers, VERSION_HEADER_KEYS);
|
||||
const buildDate =
|
||||
homeBuildDate || cpaBuildDate || this.readHeader(headers, BUILD_DATE_HEADER_KEYS);
|
||||
const runtimeKind: ServerRuntimeKind | null =
|
||||
homeVersion || homeBuildDate ? 'home' : cpaVersion || cpaBuildDate ? 'cpa' : null;
|
||||
|
||||
// 触发版本更新事件(后续通过 store 处理)
|
||||
if (version || buildDate) {
|
||||
if (version || buildDate || runtimeKind) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('server-version-update', {
|
||||
detail: { version: version || null, buildDate: buildDate || null }
|
||||
detail: { version: version || null, buildDate: buildDate || null, runtimeKind }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
+131
-6
@@ -5,14 +5,48 @@
|
||||
import { apiClient } from './client';
|
||||
import { LOGS_TIMEOUT_MS } from '@/utils/constants';
|
||||
|
||||
export type LogCursor = number | string;
|
||||
export type LogBackendKind = 'unknown' | 'file' | 'home-db';
|
||||
|
||||
export interface LogsQuery {
|
||||
after?: number;
|
||||
after?: LogCursor;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CPALogsResponse {
|
||||
lines: string[];
|
||||
'line-count': number;
|
||||
'latest-timestamp': number;
|
||||
}
|
||||
|
||||
export interface HomeLogRecord {
|
||||
id?: number;
|
||||
timestamp?: string | number;
|
||||
client_ip?: string;
|
||||
request_id?: string;
|
||||
home_ip?: string;
|
||||
level?: string;
|
||||
line?: string;
|
||||
created_at?: string | number;
|
||||
}
|
||||
|
||||
export interface HomeLogsResponse {
|
||||
logs?: HomeLogRecord[];
|
||||
total?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface LogsResponse {
|
||||
lines: string[];
|
||||
'line-count': number;
|
||||
'latest-timestamp': number;
|
||||
lineCount: number;
|
||||
latestCursor?: LogCursor;
|
||||
logBackendKind: LogBackendKind;
|
||||
requestLogHomeIpById?: Record<string, string>;
|
||||
total?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ErrorLogFile {
|
||||
@@ -25,9 +59,99 @@ export interface ErrorLogsResponse {
|
||||
files?: ErrorLogFile[];
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object';
|
||||
|
||||
const stringValue = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
const unixSecondsFromValue = (value: unknown): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
const text = stringValue(value);
|
||||
if (!text) return 0;
|
||||
const asNumber = Number(text);
|
||||
if (Number.isFinite(asNumber)) return asNumber;
|
||||
const asDate = Date.parse(text);
|
||||
return Number.isFinite(asDate) ? Math.floor(asDate / 1000) : 0;
|
||||
};
|
||||
|
||||
const homeCursorFromRecord = (record: HomeLogRecord): string => {
|
||||
const timestamp = stringValue(record.timestamp);
|
||||
if (timestamp) return timestamp;
|
||||
const createdAt = stringValue(record.created_at);
|
||||
return createdAt;
|
||||
};
|
||||
|
||||
const normalizeCPALogs = (data: Record<string, unknown>): LogsResponse => {
|
||||
const lines = Array.isArray(data.lines)
|
||||
? data.lines.filter((line): line is string => typeof line === 'string')
|
||||
: [];
|
||||
const latestTimestamp = unixSecondsFromValue(data['latest-timestamp']);
|
||||
const lineCount = Number(data['line-count']);
|
||||
|
||||
return {
|
||||
lines,
|
||||
lineCount: Number.isFinite(lineCount) ? lineCount : lines.length,
|
||||
latestCursor: latestTimestamp > 0 ? latestTimestamp : undefined,
|
||||
logBackendKind: 'file'
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeHomeLogs = (data: Record<string, unknown>): LogsResponse => {
|
||||
const rawLogs = Array.isArray(data.logs)
|
||||
? data.logs.filter((entry): entry is HomeLogRecord => isRecord(entry))
|
||||
: [];
|
||||
const orderedLogs = [...rawLogs].reverse();
|
||||
const lines = orderedLogs
|
||||
.map((record) => record.line)
|
||||
.filter((line): line is string => typeof line === 'string' && line.length > 0);
|
||||
const requestLogHomeIpById = orderedLogs.reduce<Record<string, string>>((acc, record) => {
|
||||
const requestId = stringValue(record.request_id);
|
||||
const homeIp = stringValue(record.home_ip);
|
||||
if (requestId && homeIp) {
|
||||
acc[requestId] = homeIp;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
const latestCursor = rawLogs.reduce<string | undefined>((latest, record) => {
|
||||
const cursor = homeCursorFromRecord(record);
|
||||
if (!cursor) return latest;
|
||||
if (!latest) return cursor;
|
||||
const latestTime = Date.parse(latest);
|
||||
const cursorTime = Date.parse(cursor);
|
||||
if (!Number.isFinite(latestTime) || !Number.isFinite(cursorTime)) return latest;
|
||||
return cursorTime > latestTime ? cursor : latest;
|
||||
}, undefined);
|
||||
|
||||
const total = Number(data.total);
|
||||
const limit = Number(data.limit);
|
||||
const offset = Number(data.offset);
|
||||
|
||||
return {
|
||||
lines,
|
||||
lineCount: Number.isFinite(total) ? total : lines.length,
|
||||
latestCursor,
|
||||
logBackendKind: 'home-db',
|
||||
requestLogHomeIpById,
|
||||
total: Number.isFinite(total) ? total : undefined,
|
||||
limit: Number.isFinite(limit) ? limit : undefined,
|
||||
offset: Number.isFinite(offset) ? offset : undefined
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeLogsResponse = (data: unknown): LogsResponse => {
|
||||
if (!isRecord(data)) {
|
||||
return { lines: [], lineCount: 0, logBackendKind: 'unknown' };
|
||||
}
|
||||
if (Array.isArray(data.logs)) return normalizeHomeLogs(data);
|
||||
if (Array.isArray(data.lines)) return normalizeCPALogs(data);
|
||||
return { lines: [], lineCount: 0, logBackendKind: 'unknown' };
|
||||
};
|
||||
|
||||
export const logsApi = {
|
||||
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> =>
|
||||
apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS }),
|
||||
async fetchLogs(params: LogsQuery = {}): Promise<LogsResponse> {
|
||||
const data = await apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS });
|
||||
return normalizeLogsResponse(data);
|
||||
},
|
||||
|
||||
clearLogs: () => apiClient.delete('/logs'),
|
||||
|
||||
@@ -40,8 +164,9 @@ export const logsApi = {
|
||||
timeout: LOGS_TIMEOUT_MS
|
||||
}),
|
||||
|
||||
downloadRequestLogById: (id: string) =>
|
||||
downloadRequestLogById: (id: string, homeIp?: string) =>
|
||||
apiClient.getRaw(`/request-log-by-id/${encodeURIComponent(id)}`, {
|
||||
params: homeIp ? { home_ip: homeIp } : undefined,
|
||||
responseType: 'blob',
|
||||
timeout: LOGS_TIMEOUT_MS
|
||||
}),
|
||||
|
||||
@@ -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))}`),
|
||||
};
|
||||
|
||||
@@ -3,7 +3,24 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { ServerRuntimeKind } from '@/types';
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object';
|
||||
|
||||
export const versionApi = {
|
||||
checkLatest: () => apiClient.get<Record<string, unknown>>('/latest-version')
|
||||
checkLatest: () => apiClient.get<Record<string, unknown>>('/latest-version'),
|
||||
|
||||
async detectRuntimeKind(): Promise<ServerRuntimeKind> {
|
||||
try {
|
||||
const data = await apiClient.get('/nodes');
|
||||
return isRecord(data) && Array.isArray(data.nodes) ? 'home' : 'unknown';
|
||||
} catch (error: unknown) {
|
||||
const status = isRecord(error) ? error.status : undefined;
|
||||
if (status === 404 || status === 405) {
|
||||
return 'cpa';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { AuthState, LoginCredentials, ConnectionStatus } from '@/types';
|
||||
import type { AuthState, LoginCredentials, ConnectionStatus, ServerRuntimeKind } from '@/types';
|
||||
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
||||
import { obfuscatedStorage } from '@/services/storage/secureStorage';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import { versionApi } from '@/services/api/version';
|
||||
import { useConfigStore } from './useConfigStore';
|
||||
import { useModelsStore } from './useModelsStore';
|
||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||
@@ -22,12 +23,26 @@ interface AuthStoreState extends AuthState {
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<boolean>;
|
||||
restoreSession: () => Promise<boolean>;
|
||||
updateServerVersion: (version: string | null, buildDate?: string | null) => void;
|
||||
updateServerVersion: (
|
||||
version: string | null,
|
||||
buildDate?: string | null,
|
||||
runtimeKind?: ServerRuntimeKind | null
|
||||
) => void;
|
||||
updateServerRuntimeKind: (runtimeKind: ServerRuntimeKind) => void;
|
||||
updateConnectionStatus: (status: ConnectionStatus, error?: string | null) => void;
|
||||
}
|
||||
|
||||
let restoreSessionPromise: Promise<boolean> | null = null;
|
||||
|
||||
const detectRuntimeKind = async (): Promise<ServerRuntimeKind> => {
|
||||
try {
|
||||
return await versionApi.detectRuntimeKind();
|
||||
} catch (error) {
|
||||
console.warn('Runtime kind detection failed:', error);
|
||||
return 'unknown';
|
||||
}
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AuthStoreState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -38,6 +53,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
rememberPassword: false,
|
||||
serverVersion: null,
|
||||
serverBuildDate: null,
|
||||
serverRuntimeKind: 'unknown',
|
||||
connectionStatus: 'disconnected',
|
||||
connectionError: null,
|
||||
|
||||
@@ -93,7 +109,12 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
const rememberPassword = credentials.rememberPassword ?? get().rememberPassword ?? false;
|
||||
|
||||
try {
|
||||
set({ connectionStatus: 'connecting' });
|
||||
set({
|
||||
connectionStatus: 'connecting',
|
||||
serverVersion: null,
|
||||
serverBuildDate: null,
|
||||
serverRuntimeKind: 'unknown'
|
||||
});
|
||||
useModelsStore.getState().clearCache();
|
||||
|
||||
// 配置 API 客户端
|
||||
@@ -104,6 +125,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
|
||||
// 测试连接 - 获取配置
|
||||
await useConfigStore.getState().fetchConfig(undefined, true);
|
||||
const runtimeKind = await detectRuntimeKind();
|
||||
|
||||
// 登录成功
|
||||
set({
|
||||
@@ -112,7 +134,8 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
managementKey,
|
||||
rememberPassword,
|
||||
connectionStatus: 'connected',
|
||||
connectionError: null
|
||||
connectionError: null,
|
||||
...(runtimeKind !== 'unknown' ? { serverRuntimeKind: runtimeKind } : {})
|
||||
});
|
||||
if (rememberPassword) {
|
||||
localStorage.setItem('isLoggedIn', 'true');
|
||||
@@ -145,6 +168,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
managementKey: '',
|
||||
serverVersion: null,
|
||||
serverBuildDate: null,
|
||||
serverRuntimeKind: 'unknown',
|
||||
connectionStatus: 'disconnected',
|
||||
connectionError: null
|
||||
});
|
||||
@@ -165,10 +189,12 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
|
||||
// 验证连接
|
||||
await useConfigStore.getState().fetchConfig();
|
||||
const runtimeKind = await detectRuntimeKind();
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
connectionStatus: 'connected'
|
||||
connectionStatus: 'connected',
|
||||
...(runtimeKind !== 'unknown' ? { serverRuntimeKind: runtimeKind } : {})
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -182,8 +208,16 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
},
|
||||
|
||||
// 更新服务器版本
|
||||
updateServerVersion: (version, buildDate) => {
|
||||
set({ serverVersion: version || null, serverBuildDate: buildDate || null });
|
||||
updateServerVersion: (version, buildDate, runtimeKind) => {
|
||||
set((state) => ({
|
||||
serverVersion: version || null,
|
||||
serverBuildDate: buildDate || null,
|
||||
serverRuntimeKind: runtimeKind || state.serverRuntimeKind
|
||||
}));
|
||||
},
|
||||
|
||||
updateServerRuntimeKind: (runtimeKind) => {
|
||||
set({ serverRuntimeKind: runtimeKind });
|
||||
},
|
||||
|
||||
// 更新连接状态
|
||||
@@ -213,7 +247,8 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
...(state.rememberPassword ? { managementKey: state.managementKey } : {}),
|
||||
rememberPassword: state.rememberPassword,
|
||||
serverVersion: state.serverVersion,
|
||||
serverBuildDate: state.serverBuildDate
|
||||
serverBuildDate: state.serverBuildDate,
|
||||
serverRuntimeKind: state.serverRuntimeKind
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -229,7 +264,13 @@ if (typeof window !== 'undefined') {
|
||||
'server-version-update',
|
||||
((e: CustomEvent) => {
|
||||
const detail = e.detail || {};
|
||||
useAuthStore.getState().updateServerVersion(detail.version || null, detail.buildDate || null);
|
||||
const runtimeKind =
|
||||
detail.runtimeKind === 'cpa' || detail.runtimeKind === 'home'
|
||||
? detail.runtimeKind
|
||||
: null;
|
||||
useAuthStore
|
||||
.getState()
|
||||
.updateServerVersion(detail.version || null, detail.buildDate || null, runtimeKind);
|
||||
}) as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ export interface AuthState {
|
||||
rememberPassword: boolean;
|
||||
serverVersion: string | null;
|
||||
serverBuildDate: string | null;
|
||||
serverRuntimeKind: ServerRuntimeKind;
|
||||
}
|
||||
|
||||
// 连接状态
|
||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'connecting' | 'error';
|
||||
export type ServerRuntimeKind = 'unknown' | 'cpa' | 'home';
|
||||
|
||||
export interface ConnectionInfo {
|
||||
status: ConnectionStatus;
|
||||
|
||||
+14
-2
@@ -16,8 +16,20 @@ export const CACHE_EXPIRY_MS = 30 * 1000; // 与基线保持一致,减少管
|
||||
export const DEFAULT_API_PORT = 8317;
|
||||
export const MANAGEMENT_API_PREFIX = '/v0/management';
|
||||
export const REQUEST_TIMEOUT_MS = 30 * 1000;
|
||||
export const VERSION_HEADER_KEYS = ['x-cpa-version', 'x-server-version'];
|
||||
export const BUILD_DATE_HEADER_KEYS = ['x-cpa-build-date', 'x-server-build-date'];
|
||||
export const CPA_VERSION_HEADER_KEYS = ['x-cpa-version'];
|
||||
export const CPA_BUILD_DATE_HEADER_KEYS = ['x-cpa-build-date'];
|
||||
export const HOME_VERSION_HEADER_KEYS = ['x-cpa-home-version'];
|
||||
export const HOME_BUILD_DATE_HEADER_KEYS = ['x-cpa-home-build-date'];
|
||||
export const VERSION_HEADER_KEYS = [
|
||||
...HOME_VERSION_HEADER_KEYS,
|
||||
...CPA_VERSION_HEADER_KEYS,
|
||||
'x-server-version'
|
||||
];
|
||||
export const BUILD_DATE_HEADER_KEYS = [
|
||||
...HOME_BUILD_DATE_HEADER_KEYS,
|
||||
...CPA_BUILD_DATE_HEADER_KEYS,
|
||||
'x-server-build-date'
|
||||
];
|
||||
|
||||
// 日志相关
|
||||
export const LOGS_TIMEOUT_MS = 60 * 1000;
|
||||
|
||||
@@ -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