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
20 Commits
+29
-18
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom';
|
||||
import { Outlet, RouterProvider, createHashRouter } from 'react-router-dom';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
|
||||
@@ -7,6 +7,33 @@ import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
||||
import { useLanguageStore, useThemeStore } from '@/stores';
|
||||
|
||||
function RootShell() {
|
||||
return (
|
||||
<>
|
||||
<NotificationContainer />
|
||||
<ConfirmationModal />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
element: <RootShell />,
|
||||
children: [
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{
|
||||
path: '/*',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
function App() {
|
||||
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
@@ -26,23 +53,7 @@ function App() {
|
||||
document.documentElement.lang = language;
|
||||
}, [language]);
|
||||
|
||||
return (
|
||||
<HashRouter>
|
||||
<NotificationContainer />
|
||||
<ConfirmationModal />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
);
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -111,6 +111,12 @@ export function ClaudeSection({
|
||||
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||
</div>
|
||||
{item.priority !== undefined && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
|
||||
<span className={styles.fieldValue}>{item.priority}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.prefix && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||
@@ -129,6 +135,32 @@ export function ClaudeSection({
|
||||
<span className={styles.fieldValue}>{item.proxyUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.cloak && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.claude_cloak_mode_label')}:</span>
|
||||
<span className={styles.fieldValue}>
|
||||
{(() => {
|
||||
const raw = (item.cloak?.mode ?? '').trim().toLowerCase();
|
||||
const key = raw === 'always' || raw === 'never' ? raw : 'auto';
|
||||
return t(`ai_providers.claude_cloak_mode_${key}`);
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.cloak?.strictMode ? (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.claude_cloak_strict_label')}:</span>
|
||||
<span className={styles.fieldValue}>{t('common.yes')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{item.cloak?.sensitiveWords?.length ? (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>
|
||||
{t('ai_providers.claude_cloak_sensitive_words_count')}:
|
||||
</span>
|
||||
<span className={styles.fieldValue}>{item.cloak.sensitiveWords.length}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{headerEntries.length > 0 && (
|
||||
<div className={styles.headerBadgeList}>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
|
||||
@@ -118,6 +118,12 @@ export function CodexSection({
|
||||
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||
</div>
|
||||
{item.priority !== undefined && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
|
||||
<span className={styles.fieldValue}>{item.priority}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.prefix && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||
@@ -136,6 +142,12 @@ export function CodexSection({
|
||||
<span className={styles.fieldValue}>{item.proxyUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.websockets !== undefined && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.codex_websockets_label')}:</span>
|
||||
<span className={styles.fieldValue}>{item.websockets ? t('common.yes') : t('common.no')}</span>
|
||||
</div>
|
||||
)}
|
||||
{headerEntries.length > 0 && (
|
||||
<div className={styles.headerBadgeList}>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
@@ -150,6 +162,21 @@ export function CodexSection({
|
||||
{t('ai_providers.config_disabled_badge')}
|
||||
</div>
|
||||
)}
|
||||
{item.models?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
<span className={styles.modelCountLabel}>
|
||||
{t('ai_providers.codex_models_count')}: {item.models.length}
|
||||
</span>
|
||||
{item.models.map((model) => (
|
||||
<span key={model.name} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && model.alias !== model.name && (
|
||||
<span className={styles.modelAlias}>{model.alias}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{excludedModels.length ? (
|
||||
<div className={styles.excludedModelsSection}>
|
||||
<div className={styles.excludedModelsLabel}>
|
||||
|
||||
@@ -113,6 +113,12 @@ export function GeminiSection({
|
||||
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||
</div>
|
||||
{item.priority !== undefined && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
|
||||
<span className={styles.fieldValue}>{item.priority}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.prefix && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||
@@ -145,6 +151,21 @@ export function GeminiSection({
|
||||
{t('ai_providers.config_disabled_badge')}
|
||||
</div>
|
||||
)}
|
||||
{item.models?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
<span className={styles.modelCountLabel}>
|
||||
{t('ai_providers.gemini_models_count')}: {item.models.length}
|
||||
</span>
|
||||
{item.models.map((model) => (
|
||||
<span key={model.name} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && model.alias !== model.name && (
|
||||
<span className={styles.modelAlias}>{model.alias}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{excludedModels.length ? (
|
||||
<div className={styles.excludedModelsSection}>
|
||||
<div className={styles.excludedModelsLabel}>
|
||||
|
||||
@@ -102,6 +102,12 @@ export function OpenAISection({
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="item-title">{item.name}</div>
|
||||
{item.priority !== undefined && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
|
||||
<span className={styles.fieldValue}>{item.priority}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.prefix && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||
|
||||
@@ -1,37 +1,26 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useInterval } from '@/hooks/useInterval';
|
||||
import { usageApi } from '@/services/api';
|
||||
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
|
||||
const EMPTY_STATS: KeyStats = { bySource: {}, byAuthIndex: {} };
|
||||
import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
|
||||
|
||||
export const useProviderStats = () => {
|
||||
const [keyStats, setKeyStats] = useState<KeyStats>(EMPTY_STATS);
|
||||
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
const keyStats = useUsageStatsStore((state) => state.keyStats);
|
||||
const usageDetails = useUsageStatsStore((state) => state.usageDetails);
|
||||
const isLoading = useUsageStatsStore((state) => state.loading);
|
||||
const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats);
|
||||
|
||||
// 加载 key 统计和 usage 明细(API 层已有60秒超时)
|
||||
// 首次进入页面优先复用缓存,避免跨页面重复拉取 /usage。
|
||||
const loadKeyStats = useCallback(async () => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const usageResponse = await usageApi.getUsage();
|
||||
const usageData = usageResponse?.usage ?? usageResponse;
|
||||
const stats = await usageApi.getKeyStats(usageData);
|
||||
setKeyStats(stats);
|
||||
setUsageDetails(collectUsageDetails(usageData));
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
await loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS });
|
||||
}, [loadUsageStats]);
|
||||
|
||||
// 定时刷新状态数据(每240秒)
|
||||
useInterval(loadKeyStats, 240_000);
|
||||
// 定时器触发时强制刷新共享 usage。
|
||||
const refreshKeyStats = useCallback(async () => {
|
||||
await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS });
|
||||
}, [loadUsageStats]);
|
||||
|
||||
return { keyStats, usageDetails, loadKeyStats, isLoading };
|
||||
useInterval(() => {
|
||||
void refreshKeyStats();
|
||||
}, 240_000);
|
||||
|
||||
return { keyStats, usageDetails, loadKeyStats, refreshKeyStats, isLoading };
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ModelEntry {
|
||||
|
||||
export interface OpenAIFormState {
|
||||
name: string;
|
||||
priority?: number;
|
||||
prefix: string;
|
||||
baseUrl: string;
|
||||
headers: HeaderEntry[];
|
||||
@@ -24,8 +25,9 @@ export interface AmpcodeFormState {
|
||||
mappingEntries: ModelEntry[];
|
||||
}
|
||||
|
||||
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers'> & {
|
||||
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers' | 'models'> & {
|
||||
headers: HeaderEntry[];
|
||||
modelEntries: ModelEntry[];
|
||||
excludedText: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { authFilesApi } from '@/services/api/authFiles';
|
||||
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
|
||||
import type { AuthFileItem } from '@/types/authFile';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
collectUsageDetails,
|
||||
extractTotalTokens
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
const ALL_FILTER = '__all__';
|
||||
const MAX_RENDERED_EVENTS = 500;
|
||||
|
||||
type RequestEventRow = {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
timestampMs: number;
|
||||
timestampLabel: string;
|
||||
model: string;
|
||||
sourceRaw: string;
|
||||
source: string;
|
||||
sourceType: string;
|
||||
authIndex: string;
|
||||
failed: boolean;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningTokens: number;
|
||||
cachedTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
export interface RequestEventsDetailsCardProps {
|
||||
usage: unknown;
|
||||
loading: boolean;
|
||||
geminiKeys: GeminiKeyConfig[];
|
||||
claudeConfigs: ProviderKeyConfig[];
|
||||
codexConfigs: ProviderKeyConfig[];
|
||||
vertexConfigs: ProviderKeyConfig[];
|
||||
openaiProviders: OpenAIProviderConfig[];
|
||||
}
|
||||
|
||||
const toNumber = (value: unknown): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return 0;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const encodeCsv = (value: string | number): string => {
|
||||
const text = String(value ?? '');
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
const downloadFile = (filename: string, content: string, mimeType: string) => {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
type CredentialInfo = {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type SourceInfo = {
|
||||
displayName: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function normalizeAuthIndexValue(value: unknown): string | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RequestEventsDetailsCard({
|
||||
usage,
|
||||
loading,
|
||||
geminiKeys,
|
||||
claudeConfigs,
|
||||
codexConfigs,
|
||||
vertexConfigs,
|
||||
openaiProviders
|
||||
}: RequestEventsDetailsCardProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [modelFilter, setModelFilter] = useState(ALL_FILTER);
|
||||
const [sourceFilter, setSourceFilter] = useState(ALL_FILTER);
|
||||
const [authIndexFilter, setAuthIndexFilter] = useState(ALL_FILTER);
|
||||
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
authFilesApi
|
||||
.list()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
|
||||
if (!Array.isArray(files)) return;
|
||||
const map = new Map<string, CredentialInfo>();
|
||||
files.forEach((file) => {
|
||||
const key = normalizeAuthIndexValue(file['auth_index'] ?? file.authIndex);
|
||||
if (!key) return;
|
||||
map.set(key, {
|
||||
name: file.name || key,
|
||||
type: (file.type || file.provider || '').toString()
|
||||
});
|
||||
});
|
||||
setAuthFileMap(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sourceInfoMap = useMemo(() => {
|
||||
const map = new Map<string, SourceInfo>();
|
||||
|
||||
const registerSource = (sourceId: string, displayName: string, type: string) => {
|
||||
if (!sourceId || !displayName) return;
|
||||
if (map.has(sourceId)) return;
|
||||
map.set(sourceId, { displayName, type });
|
||||
};
|
||||
|
||||
const registerCandidates = (
|
||||
displayName: string,
|
||||
type: string,
|
||||
candidates: string[]
|
||||
) => {
|
||||
candidates.forEach((sourceId) => registerSource(sourceId, displayName, type));
|
||||
};
|
||||
|
||||
geminiKeys.forEach((config, index) => {
|
||||
const displayName = config.prefix?.trim() || `Gemini #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
'gemini',
|
||||
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
|
||||
);
|
||||
});
|
||||
|
||||
claudeConfigs.forEach((config, index) => {
|
||||
const displayName = config.prefix?.trim() || `Claude #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
'claude',
|
||||
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
|
||||
);
|
||||
});
|
||||
|
||||
codexConfigs.forEach((config, index) => {
|
||||
const displayName = config.prefix?.trim() || `Codex #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
'codex',
|
||||
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
|
||||
);
|
||||
});
|
||||
|
||||
vertexConfigs.forEach((config, index) => {
|
||||
const displayName = config.prefix?.trim() || `Vertex #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
'vertex',
|
||||
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
|
||||
);
|
||||
});
|
||||
|
||||
openaiProviders.forEach((provider, providerIndex) => {
|
||||
const displayName = provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
|
||||
const candidates = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((sourceId) =>
|
||||
candidates.add(sourceId)
|
||||
);
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((sourceId) =>
|
||||
candidates.add(sourceId)
|
||||
);
|
||||
});
|
||||
registerCandidates(displayName, 'openai', Array.from(candidates));
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs]);
|
||||
|
||||
const rows = useMemo<RequestEventRow[]>(() => {
|
||||
const details = collectUsageDetails(usage);
|
||||
|
||||
return details
|
||||
.map((detail, index) => {
|
||||
const timestamp = typeof detail.timestamp === 'string' ? detail.timestamp : '';
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
|
||||
const sourceRaw = String(detail.source ?? '').trim();
|
||||
const authIndexRaw = detail.auth_index as unknown;
|
||||
const authIndex =
|
||||
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
|
||||
? '-'
|
||||
: String(authIndexRaw);
|
||||
const normalizedAuthIndex = normalizeAuthIndexValue(authIndexRaw);
|
||||
const sourceInfo = sourceInfoMap.get(sourceRaw);
|
||||
const authInfo = normalizedAuthIndex ? authFileMap.get(normalizedAuthIndex) : undefined;
|
||||
const source = sourceInfo?.displayName
|
||||
|| authInfo?.name
|
||||
|| (sourceRaw.startsWith('t:') ? sourceRaw.slice(2) : sourceRaw || '-');
|
||||
const sourceType = sourceInfo?.type || authInfo?.type || '';
|
||||
const model = String(detail.__modelName ?? '').trim() || '-';
|
||||
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
|
||||
const outputTokens = Math.max(toNumber(detail.tokens?.output_tokens), 0);
|
||||
const reasoningTokens = Math.max(toNumber(detail.tokens?.reasoning_tokens), 0);
|
||||
const cachedTokens = Math.max(
|
||||
Math.max(toNumber(detail.tokens?.cached_tokens), 0),
|
||||
Math.max(toNumber(detail.tokens?.cache_tokens), 0)
|
||||
);
|
||||
const totalTokens = Math.max(
|
||||
toNumber(detail.tokens?.total_tokens),
|
||||
extractTotalTokens(detail)
|
||||
);
|
||||
|
||||
return {
|
||||
id: `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`,
|
||||
timestamp,
|
||||
timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
|
||||
timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-',
|
||||
model,
|
||||
sourceRaw: sourceRaw || '-',
|
||||
source,
|
||||
sourceType,
|
||||
authIndex,
|
||||
failed: detail.failed === true,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cachedTokens,
|
||||
totalTokens
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.timestampMs - a.timestampMs);
|
||||
}, [authFileMap, i18n.language, sourceInfoMap, usage]);
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() => [
|
||||
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
|
||||
...Array.from(new Set(rows.map((row) => row.model))).map((model) => ({
|
||||
value: model,
|
||||
label: model
|
||||
}))
|
||||
],
|
||||
[rows, t]
|
||||
);
|
||||
|
||||
const sourceOptions = useMemo(
|
||||
() => [
|
||||
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
|
||||
...Array.from(new Set(rows.map((row) => row.source))).map((source) => ({
|
||||
value: source,
|
||||
label: source
|
||||
}))
|
||||
],
|
||||
[rows, t]
|
||||
);
|
||||
|
||||
const authIndexOptions = useMemo(
|
||||
() => [
|
||||
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
|
||||
...Array.from(new Set(rows.map((row) => row.authIndex))).map((authIndex) => ({
|
||||
value: authIndex,
|
||||
label: authIndex
|
||||
}))
|
||||
],
|
||||
[rows, t]
|
||||
);
|
||||
|
||||
const modelOptionSet = useMemo(
|
||||
() => new Set(modelOptions.map((option) => option.value)),
|
||||
[modelOptions]
|
||||
);
|
||||
const sourceOptionSet = useMemo(
|
||||
() => new Set(sourceOptions.map((option) => option.value)),
|
||||
[sourceOptions]
|
||||
);
|
||||
const authIndexOptionSet = useMemo(
|
||||
() => new Set(authIndexOptions.map((option) => option.value)),
|
||||
[authIndexOptions]
|
||||
);
|
||||
|
||||
const effectiveModelFilter = modelOptionSet.has(modelFilter) ? modelFilter : ALL_FILTER;
|
||||
const effectiveSourceFilter = sourceOptionSet.has(sourceFilter) ? sourceFilter : ALL_FILTER;
|
||||
const effectiveAuthIndexFilter = authIndexOptionSet.has(authIndexFilter)
|
||||
? authIndexFilter
|
||||
: ALL_FILTER;
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() =>
|
||||
rows.filter((row) => {
|
||||
const modelMatched = effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
|
||||
const sourceMatched = effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter;
|
||||
const authIndexMatched =
|
||||
effectiveAuthIndexFilter === ALL_FILTER || row.authIndex === effectiveAuthIndexFilter;
|
||||
return modelMatched && sourceMatched && authIndexMatched;
|
||||
}),
|
||||
[effectiveAuthIndexFilter, effectiveModelFilter, effectiveSourceFilter, rows]
|
||||
);
|
||||
|
||||
const renderedRows = useMemo(
|
||||
() => filteredRows.slice(0, MAX_RENDERED_EVENTS),
|
||||
[filteredRows]
|
||||
);
|
||||
|
||||
const hasActiveFilters =
|
||||
effectiveModelFilter !== ALL_FILTER ||
|
||||
effectiveSourceFilter !== ALL_FILTER ||
|
||||
effectiveAuthIndexFilter !== ALL_FILTER;
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setModelFilter(ALL_FILTER);
|
||||
setSourceFilter(ALL_FILTER);
|
||||
setAuthIndexFilter(ALL_FILTER);
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
if (!filteredRows.length) return;
|
||||
|
||||
const csvHeader = [
|
||||
'timestamp',
|
||||
'model',
|
||||
'source',
|
||||
'source_raw',
|
||||
'auth_index',
|
||||
'result',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'reasoning_tokens',
|
||||
'cached_tokens',
|
||||
'total_tokens'
|
||||
];
|
||||
|
||||
const csvRows = filteredRows.map((row) =>
|
||||
[
|
||||
row.timestamp,
|
||||
row.model,
|
||||
row.source,
|
||||
row.sourceRaw,
|
||||
row.authIndex,
|
||||
row.failed ? 'failed' : 'success',
|
||||
row.inputTokens,
|
||||
row.outputTokens,
|
||||
row.reasoningTokens,
|
||||
row.cachedTokens,
|
||||
row.totalTokens
|
||||
]
|
||||
.map((value) => encodeCsv(value))
|
||||
.join(',')
|
||||
);
|
||||
|
||||
const content = [csvHeader.join(','), ...csvRows].join('\n');
|
||||
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
downloadFile(`usage-events-${fileTime}.csv`, content, 'text/csv;charset=utf-8');
|
||||
};
|
||||
|
||||
const handleExportJson = () => {
|
||||
if (!filteredRows.length) return;
|
||||
|
||||
const payload = filteredRows.map((row) => ({
|
||||
timestamp: row.timestamp,
|
||||
model: row.model,
|
||||
source: row.source,
|
||||
source_raw: row.sourceRaw,
|
||||
auth_index: row.authIndex,
|
||||
failed: row.failed,
|
||||
tokens: {
|
||||
input_tokens: row.inputTokens,
|
||||
output_tokens: row.outputTokens,
|
||||
reasoning_tokens: row.reasoningTokens,
|
||||
cached_tokens: row.cachedTokens,
|
||||
total_tokens: row.totalTokens
|
||||
}
|
||||
}));
|
||||
|
||||
const content = JSON.stringify(payload, null, 2);
|
||||
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
downloadFile(`usage-events-${fileTime}.json`, content, 'application/json;charset=utf-8');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t('usage_stats.request_events_title')}
|
||||
extra={
|
||||
<div className={styles.requestEventsActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearFilters}
|
||||
disabled={!hasActiveFilters}
|
||||
>
|
||||
{t('usage_stats.clear_filters')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleExportCsv}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
{t('usage_stats.export_csv')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleExportJson}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
{t('usage_stats.export_json')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.requestEventsToolbar}>
|
||||
<div className={styles.requestEventsFilterItem}>
|
||||
<span className={styles.requestEventsFilterLabel}>
|
||||
{t('usage_stats.request_events_filter_model')}
|
||||
</span>
|
||||
<Select
|
||||
value={effectiveModelFilter}
|
||||
options={modelOptions}
|
||||
onChange={setModelFilter}
|
||||
className={styles.requestEventsSelect}
|
||||
ariaLabel={t('usage_stats.request_events_filter_model')}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.requestEventsFilterItem}>
|
||||
<span className={styles.requestEventsFilterLabel}>
|
||||
{t('usage_stats.request_events_filter_source')}
|
||||
</span>
|
||||
<Select
|
||||
value={effectiveSourceFilter}
|
||||
options={sourceOptions}
|
||||
onChange={setSourceFilter}
|
||||
className={styles.requestEventsSelect}
|
||||
ariaLabel={t('usage_stats.request_events_filter_source')}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.requestEventsFilterItem}>
|
||||
<span className={styles.requestEventsFilterLabel}>
|
||||
{t('usage_stats.request_events_filter_auth_index')}
|
||||
</span>
|
||||
<Select
|
||||
value={effectiveAuthIndexFilter}
|
||||
options={authIndexOptions}
|
||||
onChange={setAuthIndexFilter}
|
||||
className={styles.requestEventsSelect}
|
||||
ariaLabel={t('usage_stats.request_events_filter_auth_index')}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && rows.length === 0 ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('usage_stats.request_events_empty_title')}
|
||||
description={t('usage_stats.request_events_empty_desc')}
|
||||
/>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('usage_stats.request_events_no_result_title')}
|
||||
description={t('usage_stats.request_events_no_result_desc')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.requestEventsMeta}>
|
||||
<span>{t('usage_stats.request_events_count', { count: filteredRows.length })}</span>
|
||||
{filteredRows.length > MAX_RENDERED_EVENTS && (
|
||||
<span className={styles.requestEventsLimitHint}>
|
||||
{t('usage_stats.request_events_limit_hint', {
|
||||
shown: MAX_RENDERED_EVENTS,
|
||||
total: filteredRows.length
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.requestEventsTableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('usage_stats.request_events_timestamp')}</th>
|
||||
<th>{t('usage_stats.model_name')}</th>
|
||||
<th>{t('usage_stats.request_events_source')}</th>
|
||||
<th>{t('usage_stats.request_events_auth_index')}</th>
|
||||
<th>{t('usage_stats.request_events_result')}</th>
|
||||
<th>{t('usage_stats.input_tokens')}</th>
|
||||
<th>{t('usage_stats.output_tokens')}</th>
|
||||
<th>{t('usage_stats.reasoning_tokens')}</th>
|
||||
<th>{t('usage_stats.cached_tokens')}</th>
|
||||
<th>{t('usage_stats.total_tokens')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderedRows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td title={row.timestamp} className={styles.requestEventsTimestamp}>
|
||||
{row.timestampLabel}
|
||||
</td>
|
||||
<td className={styles.modelCell}>{row.model}</td>
|
||||
<td className={styles.requestEventsSourceCell} title={row.source}>
|
||||
<span>{row.source}</span>
|
||||
{row.sourceType && (
|
||||
<span className={styles.credentialType}>{row.sourceType}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.requestEventsAuthIndex} title={row.authIndex}>
|
||||
{row.authIndex}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={row.failed ? styles.requestEventsResultFailed : styles.requestEventsResultSuccess}
|
||||
>
|
||||
{row.failed ? t('stats.failure') : t('stats.success')}
|
||||
</span>
|
||||
</td>
|
||||
<td>{row.inputTokens.toLocaleString()}</td>
|
||||
<td>{row.outputTokens.toLocaleString()}</td>
|
||||
<td>{row.reasoningTokens.toLocaleString()}</td>
|
||||
<td>{row.cachedTokens.toLocaleString()}</td>
|
||||
<td>{row.totalTokens.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { USAGE_STATS_STALE_TIME_MS, useNotificationStore, useUsageStatsStore } from '@/stores';
|
||||
import { usageApi } from '@/services/api/usage';
|
||||
import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage';
|
||||
|
||||
@@ -32,36 +32,25 @@ export interface UseUsageDataReturn {
|
||||
export function useUsageData(): UseUsageDataReturn {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const usageSnapshot = useUsageStatsStore((state) => state.usage);
|
||||
const loading = useUsageStatsStore((state) => state.loading);
|
||||
const storeError = useUsageStatsStore((state) => state.error);
|
||||
const lastRefreshedAtTs = useUsageStatsStore((state) => state.lastRefreshedAt);
|
||||
const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats);
|
||||
|
||||
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await usageApi.getUsage();
|
||||
const payload = (data?.usage ?? data) as unknown;
|
||||
setUsage(payload && typeof payload === 'object' ? (payload as UsagePayload) : null);
|
||||
setLastRefreshedAt(new Date());
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS });
|
||||
}, [loadUsageStats]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsage();
|
||||
void loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS });
|
||||
setModelPrices(loadModelPrices());
|
||||
}, [loadUsage]);
|
||||
}, [loadUsageStats]);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
@@ -122,7 +111,7 @@ export function useUsageData(): UseUsageDataReturn {
|
||||
}),
|
||||
'success'
|
||||
);
|
||||
await loadUsage();
|
||||
await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
showNotification(
|
||||
@@ -139,6 +128,10 @@ export function useUsageData(): UseUsageDataReturn {
|
||||
saveModelPrices(prices);
|
||||
}, []);
|
||||
|
||||
const usage = usageSnapshot as UsagePayload | null;
|
||||
const error = storeError || '';
|
||||
const lastRefreshedAt = lastRefreshedAtTs ? new Date(lastRefreshedAtTs) : null;
|
||||
|
||||
return {
|
||||
usage,
|
||||
loading,
|
||||
|
||||
@@ -38,3 +38,6 @@ export type { CostTrendChartProps } from './CostTrendChart';
|
||||
|
||||
export { ServiceHealthCard } from './ServiceHealthCard';
|
||||
export type { ServiceHealthCardProps } from './ServiceHealthCard';
|
||||
|
||||
export { RequestEventsDetailsCard } from './RequestEventsDetailsCard';
|
||||
export type { RequestEventsDetailsCardProps } from './RequestEventsDetailsCard';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { resolveAuthProvider } from '@/utils/quota';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { formatFileSize } from '@/utils/format';
|
||||
import {
|
||||
AUTH_FILE_REFRESH_WARNING_MS,
|
||||
QUOTA_PROVIDER_TYPES,
|
||||
formatModified,
|
||||
getTypeColor,
|
||||
@@ -23,6 +24,21 @@ import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFi
|
||||
import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection';
|
||||
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||
|
||||
type AuthFileHealthStatus = 'healthy' | 'warning' | 'disabled' | 'unknown';
|
||||
|
||||
const HEALTHY_STATUS_MESSAGES = new Set(['ok', 'healthy', 'ready', 'success', 'available']);
|
||||
const GOOD_STATUS_VALUES = new Set(['', 'ok', 'ready', 'healthy', 'available']);
|
||||
|
||||
const parseDateFromUnknown = (value: unknown): Date | null => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const asNumber = Number(value);
|
||||
const date =
|
||||
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
|
||||
? new Date(Math.abs(asNumber) < 1e12 ? asNumber * 1000 : asNumber)
|
||||
: new Date(String(value));
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
};
|
||||
|
||||
export type AuthFileCardProps = {
|
||||
file: AuthFileItem;
|
||||
selected: boolean;
|
||||
@@ -33,6 +49,7 @@ export type AuthFileCardProps = {
|
||||
quotaFilterType: QuotaProviderType | null;
|
||||
keyStats: KeyStats;
|
||||
statusBarCache: Map<string, AuthFileStatusBarData>;
|
||||
nowMs: number;
|
||||
onShowModels: (file: AuthFileItem) => void;
|
||||
onShowDetails: (file: AuthFileItem) => void;
|
||||
onDownload: (name: string) => void;
|
||||
@@ -49,7 +66,7 @@ const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
|
||||
};
|
||||
|
||||
export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const {
|
||||
file,
|
||||
selected,
|
||||
@@ -60,6 +77,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
quotaFilterType,
|
||||
keyStats,
|
||||
statusBarCache,
|
||||
nowMs,
|
||||
onShowModels,
|
||||
onShowDetails,
|
||||
onDownload,
|
||||
@@ -93,6 +111,63 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||
const statusData =
|
||||
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
||||
const rawStatus = String(file.status ?? file['status'] ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const rawStatusMessage = String(file['status_message'] ?? file.statusMessage ?? '').trim();
|
||||
const normalizedStatusMessage = rawStatusMessage.toLowerCase();
|
||||
const isFileDisabled = file.disabled === true || rawStatus === 'disabled';
|
||||
const isUnavailable = file.unavailable === true || rawStatus === 'unavailable';
|
||||
const lastRefreshDate = parseDateFromUnknown(file['last_refresh'] ?? file.lastRefresh);
|
||||
const isRefreshStale = lastRefreshDate
|
||||
? nowMs - lastRefreshDate.getTime() > AUTH_FILE_REFRESH_WARNING_MS
|
||||
: false;
|
||||
const hasStatusWarning =
|
||||
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(normalizedStatusMessage);
|
||||
const hasStatusFailure = rawStatus === 'error' || rawStatus === 'failed' || rawStatus === 'warning';
|
||||
const healthStatus: AuthFileHealthStatus = isFileDisabled
|
||||
? 'disabled'
|
||||
: hasStatusWarning || hasStatusFailure || isUnavailable || isRefreshStale
|
||||
? 'warning'
|
||||
: lastRefreshDate && !isRefreshStale && GOOD_STATUS_VALUES.has(rawStatus)
|
||||
? 'healthy'
|
||||
: 'unknown';
|
||||
const healthStatusClass =
|
||||
healthStatus === 'healthy'
|
||||
? styles.healthStatusHealthy
|
||||
: healthStatus === 'warning'
|
||||
? styles.healthStatusWarning
|
||||
: healthStatus === 'disabled'
|
||||
? styles.healthStatusDisabled
|
||||
: styles.healthStatusUnknown;
|
||||
const healthStatusLabel = t(`auth_files.health_status_${healthStatus}`);
|
||||
const lastRefreshText = (() => {
|
||||
if (!lastRefreshDate) return t('auth_files.refresh_not_available');
|
||||
|
||||
const diffMs = lastRefreshDate.getTime() - nowMs;
|
||||
const absMs = Math.abs(diffMs);
|
||||
if (absMs < 30 * 1000) {
|
||||
return t('auth_files.refresh_just_now');
|
||||
}
|
||||
|
||||
const units: ReadonlyArray<{ unit: Intl.RelativeTimeFormatUnit; ms: number }> = [
|
||||
{ unit: 'day', ms: 24 * 60 * 60 * 1000 },
|
||||
{ unit: 'hour', ms: 60 * 60 * 1000 },
|
||||
{ unit: 'minute', ms: 60 * 1000 },
|
||||
{ unit: 'second', ms: 1000 }
|
||||
];
|
||||
const matched = units.find(({ ms }) => absMs >= ms) || units[units.length - 1];
|
||||
const value = Math.round(diffMs / matched.ms);
|
||||
if (typeof Intl === 'undefined' || typeof Intl.RelativeTimeFormat !== 'function') {
|
||||
return lastRefreshDate.toLocaleString(i18n.language);
|
||||
}
|
||||
const formatter = new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto' });
|
||||
return formatter.format(value, matched.unit);
|
||||
})();
|
||||
const lastRefreshTitle = lastRefreshDate
|
||||
? lastRefreshDate.toLocaleString(i18n.language)
|
||||
: t('auth_files.refresh_not_available');
|
||||
const healthStatusTitle = rawStatusMessage || t('auth_files.health_status_no_message');
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -135,6 +210,23 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.cardHealthRow}>
|
||||
<span className={`${styles.healthStatusBadge} ${healthStatusClass}`} title={healthStatusTitle}>
|
||||
{t('auth_files.health_status_label')}: {healthStatusLabel}
|
||||
</span>
|
||||
<span
|
||||
className={`${styles.lastRefreshText} ${isRefreshStale ? styles.lastRefreshStale : ''}`}
|
||||
title={lastRefreshTitle}
|
||||
>
|
||||
{t('auth_files.last_refresh_label')}: {lastRefreshText}
|
||||
</span>
|
||||
</div>
|
||||
{rawStatusMessage && hasStatusWarning && (
|
||||
<div className={styles.healthStatusMessage} title={rawStatusMessage}>
|
||||
{rawStatusMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.cardStats}>
|
||||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
{t('stats.success')}: {fileStats.success}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', '
|
||||
|
||||
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;
|
||||
|
||||
export const INTEGER_STRING_PATTERN = /^[+-]?\d+$/;
|
||||
export const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']);
|
||||
|
||||
@@ -1,35 +1,26 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { usageApi } from '@/services/api';
|
||||
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import { useCallback } from 'react';
|
||||
import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
|
||||
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
||||
|
||||
export type UseAuthFilesStatsResult = {
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
loadKeyStats: () => Promise<void>;
|
||||
refreshKeyStats: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useAuthFilesStats(): UseAuthFilesStatsResult {
|
||||
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
||||
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||
const loadingKeyStatsRef = useRef(false);
|
||||
const keyStats = useUsageStatsStore((state) => state.keyStats);
|
||||
const usageDetails = useUsageStatsStore((state) => state.usageDetails);
|
||||
const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats);
|
||||
|
||||
const loadKeyStats = useCallback(async () => {
|
||||
if (loadingKeyStatsRef.current) return;
|
||||
loadingKeyStatsRef.current = true;
|
||||
try {
|
||||
const usageResponse = await usageApi.getUsage();
|
||||
const usageData = usageResponse?.usage ?? usageResponse;
|
||||
const stats = await usageApi.getKeyStats(usageData);
|
||||
setKeyStats(stats);
|
||||
const details = collectUsageDetails(usageData);
|
||||
setUsageDetails(details);
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
loadingKeyStatsRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
await loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS });
|
||||
}, [loadUsageStats]);
|
||||
|
||||
return { keyStats, usageDetails, loadKeyStats };
|
||||
const refreshKeyStats = useCallback(async () => {
|
||||
await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS });
|
||||
}, [loadUsageStats]);
|
||||
|
||||
return { keyStats, usageDetails, loadKeyStats, refreshKeyStats };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type { BlockerFunction } from 'react-router';
|
||||
import { useBlocker, useLocation } from 'react-router';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
|
||||
type ConfirmationVariant = 'danger' | 'primary' | 'secondary';
|
||||
|
||||
export type UnsavedChangesDialog = {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
variant?: ConfirmationVariant;
|
||||
};
|
||||
|
||||
export type UseUnsavedChangesGuardOptions = {
|
||||
enabled?: boolean;
|
||||
shouldBlock: boolean | BlockerFunction;
|
||||
dialog: UnsavedChangesDialog;
|
||||
};
|
||||
|
||||
export function useUnsavedChangesGuard(options: UseUnsavedChangesGuardOptions) {
|
||||
const { enabled = true, shouldBlock, dialog } = options;
|
||||
const { showConfirmation } = useNotificationStore();
|
||||
const lastBlockedRef = useRef<string>('');
|
||||
const allowNextNavigationUntilRef = useRef(0);
|
||||
const location = useLocation();
|
||||
|
||||
const allowNextNavigation = useCallback(() => {
|
||||
// Allow a short window for programmatic navigations after successful save.
|
||||
// This avoids stale "allow" flags lingering when no navigation happens.
|
||||
allowNextNavigationUntilRef.current = Date.now() + 2_000;
|
||||
}, []);
|
||||
|
||||
const shouldBlockFunction = useCallback<BlockerFunction>(
|
||||
(args) => {
|
||||
if (!enabled) return false;
|
||||
if (allowNextNavigationUntilRef.current > Date.now()) {
|
||||
return false;
|
||||
}
|
||||
return typeof shouldBlock === 'function' ? shouldBlock(args) : shouldBlock;
|
||||
},
|
||||
[enabled, shouldBlock]
|
||||
);
|
||||
|
||||
const blocker = useBlocker(shouldBlockFunction);
|
||||
|
||||
useEffect(() => {
|
||||
if (allowNextNavigationUntilRef.current === 0) return;
|
||||
allowNextNavigationUntilRef.current = 0;
|
||||
}, [location.key]);
|
||||
|
||||
const blockedKey = useMemo(() => {
|
||||
if (blocker.state !== 'blocked' || !blocker.location) return '';
|
||||
return `${blocker.location.pathname}${blocker.location.search}${blocker.location.hash}`;
|
||||
}, [blocker.location, blocker.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state !== 'blocked') {
|
||||
lastBlockedRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blockedKey || lastBlockedRef.current === blockedKey) {
|
||||
return;
|
||||
}
|
||||
lastBlockedRef.current = blockedKey;
|
||||
|
||||
showConfirmation({
|
||||
title: dialog.title,
|
||||
message: dialog.message,
|
||||
confirmText: dialog.confirmText,
|
||||
cancelText: dialog.cancelText,
|
||||
variant: dialog.variant ?? 'danger',
|
||||
onConfirm: () => blocker.proceed(),
|
||||
onCancel: () => blocker.reset(),
|
||||
});
|
||||
}, [blockedKey, blocker, dialog, showConfirmation]);
|
||||
|
||||
return { allowNextNavigation };
|
||||
}
|
||||
+123
-1
@@ -5,6 +5,8 @@
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"leave": "Leave",
|
||||
"stay": "Stay",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
@@ -32,6 +34,7 @@
|
||||
"base_url": "Address",
|
||||
"prefix": "Prefix",
|
||||
"proxy_url": "Proxy",
|
||||
"priority": "Priority",
|
||||
"alias": "Alias",
|
||||
"failure": "Failure",
|
||||
"unknown_error": "Unknown error",
|
||||
@@ -47,7 +50,9 @@
|
||||
"custom_headers_value_placeholder": "Header value",
|
||||
"model_name_placeholder": "Model name, e.g. claude-3-5-sonnet-20241022",
|
||||
"model_alias_placeholder": "Model alias (optional)",
|
||||
"invalid_provider_index": "Invalid provider index."
|
||||
"invalid_provider_index": "Invalid provider index.",
|
||||
"unsaved_changes_title": "Unsaved changes",
|
||||
"unsaved_changes_message": "You have unsaved changes. Leaving now will discard them. Do you want to leave?"
|
||||
},
|
||||
"title": {
|
||||
"main": "CLI Proxy API Management Center",
|
||||
@@ -131,6 +136,7 @@
|
||||
"no_usage_data": "No usage data available",
|
||||
"view_detailed_usage": "View Detailed Stats",
|
||||
"edit_settings": "Edit Settings",
|
||||
"routing_strategy": "Routing Strategy",
|
||||
"available_models": "Available Models",
|
||||
"available_models_desc": "Total models from all providers"
|
||||
},
|
||||
@@ -200,6 +206,23 @@
|
||||
"gemini_base_url_placeholder": "e.g.: https://generativelanguage.googleapis.com",
|
||||
"gemini_add_modal_proxy_label": "Proxy URL (Optional):",
|
||||
"gemini_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
|
||||
"gemini_models_label": "Custom Models (Optional):",
|
||||
"gemini_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them for this key.",
|
||||
"gemini_models_add_btn": "Add Model",
|
||||
"gemini_models_fetch_button": "Fetch via /v1beta/models",
|
||||
"gemini_models_fetch_title": "Pick Models from Gemini /v1beta/models",
|
||||
"gemini_models_fetch_hint": "Call GET /v1beta/models per the Gemini API. By default, it sends x-goog-api-key from the API Key field, merged with your custom headers.",
|
||||
"gemini_models_fetch_url_label": "Request URL",
|
||||
"gemini_models_fetch_refresh": "Refresh",
|
||||
"gemini_models_fetch_loading": "Fetching models from Gemini /v1beta/models...",
|
||||
"gemini_models_fetch_empty": "No models returned. Please check Base URL, API key, or headers.",
|
||||
"gemini_models_fetch_error": "Failed to fetch Gemini models",
|
||||
"gemini_models_fetch_apply": "Add selected models",
|
||||
"gemini_models_search_label": "Search models",
|
||||
"gemini_models_search_placeholder": "Filter by name, alias, or description",
|
||||
"gemini_models_search_empty": "No models match your search. Try a different keyword.",
|
||||
"gemini_models_fetch_added": "{{count}} new models added",
|
||||
"gemini_models_count": "Models Count",
|
||||
"gemini_edit_modal_title": "Edit Gemini API Key",
|
||||
"gemini_edit_modal_key_label": "API Key:",
|
||||
"gemini_delete_confirm": "Are you sure you want to delete this Gemini key?",
|
||||
@@ -210,6 +233,8 @@
|
||||
"prefix_label": "Prefix (Optional):",
|
||||
"prefix_placeholder": "e.g.: team-a",
|
||||
"prefix_hint": "When set, call models as prefix/<model> to target this entry.",
|
||||
"priority_label": "Priority (Optional):",
|
||||
"priority_hint": "Higher values are preferred. Leave empty for default (0).",
|
||||
"config_toggle_label": "Enabled",
|
||||
"config_disabled_badge": "Disabled",
|
||||
"codex_title": "Codex API Configuration",
|
||||
@@ -224,6 +249,25 @@
|
||||
"codex_add_modal_url_placeholder": "e.g.: https://api.example.com",
|
||||
"codex_add_modal_proxy_label": "Proxy URL (Optional):",
|
||||
"codex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
|
||||
"codex_websockets_label": "Websockets",
|
||||
"codex_websockets_hint": "Enable Responses API websocket transport for this key.",
|
||||
"codex_models_label": "Custom Models (Optional):",
|
||||
"codex_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
|
||||
"codex_models_add_btn": "Add Model",
|
||||
"codex_models_fetch_button": "Fetch via /v1/models",
|
||||
"codex_models_fetch_title": "Pick Models from /v1/models",
|
||||
"codex_models_fetch_hint": "Call GET /v1/models using the Base URL above, sending the API key as Bearer (if provided) and merging your custom headers.",
|
||||
"codex_models_fetch_url_label": "Request URL",
|
||||
"codex_models_fetch_refresh": "Refresh",
|
||||
"codex_models_fetch_loading": "Fetching models from /v1/models...",
|
||||
"codex_models_fetch_empty": "No models returned. Please check Base URL, API key, or headers.",
|
||||
"codex_models_fetch_error": "Failed to fetch models",
|
||||
"codex_models_fetch_apply": "Add selected models",
|
||||
"codex_models_search_label": "Search models",
|
||||
"codex_models_search_placeholder": "Filter by name, alias, or description",
|
||||
"codex_models_search_empty": "No models match your search. Try a different keyword.",
|
||||
"codex_models_fetch_added": "{{count}} new models added",
|
||||
"codex_models_count": "Models Count",
|
||||
"codex_edit_modal_title": "Edit Codex API Configuration",
|
||||
"codex_edit_modal_key_label": "API Key:",
|
||||
"codex_edit_modal_url_label": "Base URL (Required):",
|
||||
@@ -275,6 +319,21 @@
|
||||
"claude_test_key_required": "Please provide a Claude API key or set x-api-key in custom headers",
|
||||
"claude_test_model_required": "Please select a model to test",
|
||||
"claude_test_endpoint_invalid": "Unable to build a valid Claude /v1/messages endpoint",
|
||||
"claude_cloak_title": "Request Cloaking (Optional):",
|
||||
"claude_cloak_toggle_label": "Enable",
|
||||
"claude_cloak_toggle_aria": "Toggle request cloaking",
|
||||
"claude_cloak_hint": "Cloaking disguises requests to appear as coming from the official Claude Code CLI for non-Claude-Code clients.",
|
||||
"claude_cloak_mode_label": "Mode:",
|
||||
"claude_cloak_mode_hint": "auto: cloak only when client is not Claude Code; always: always cloak; never: never cloak.",
|
||||
"claude_cloak_mode_auto": "Auto (non-Claude-Code only)",
|
||||
"claude_cloak_mode_always": "Always",
|
||||
"claude_cloak_mode_never": "Never",
|
||||
"claude_cloak_strict_label": "Strict mode:",
|
||||
"claude_cloak_strict_hint": "When enabled, strip user system messages and keep only the Claude Code prompt.",
|
||||
"claude_cloak_sensitive_words_label": "Sensitive words (optional):",
|
||||
"claude_cloak_sensitive_words_placeholder": "Comma or newline separated, e.g. internal-project",
|
||||
"claude_cloak_sensitive_words_hint": "These words are obfuscated with zero-width characters.",
|
||||
"claude_cloak_sensitive_words_count": "Sensitive words",
|
||||
"vertex_title": "Vertex API Configuration",
|
||||
"vertex_add_button": "Add Configuration",
|
||||
"vertex_empty_title": "No Vertex Configuration",
|
||||
@@ -387,6 +446,15 @@
|
||||
"search_empty_desc": "Try changing the filters or clearing the search box.",
|
||||
"file_size": "Size",
|
||||
"file_modified": "Modified",
|
||||
"health_status_label": "Health",
|
||||
"health_status_healthy": "Healthy",
|
||||
"health_status_warning": "Warning",
|
||||
"health_status_disabled": "Disabled",
|
||||
"health_status_unknown": "Unknown",
|
||||
"health_status_no_message": "No status message",
|
||||
"last_refresh_label": "Last Refresh",
|
||||
"refresh_not_available": "N/A",
|
||||
"refresh_just_now": "Just now",
|
||||
"download_button": "Download",
|
||||
"delete_button": "Delete",
|
||||
"delete_confirm": "Are you sure you want to delete file",
|
||||
@@ -807,9 +875,13 @@
|
||||
"range_7h": "Last 7 Hours",
|
||||
"range_24h": "Last 24 Hours",
|
||||
"range_7d": "Last 7 Days",
|
||||
"filter_all": "All",
|
||||
"clear_filters": "Clear Filters",
|
||||
"refresh": "Refresh",
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
"export_csv": "Export CSV",
|
||||
"export_json": "Export JSON",
|
||||
"export_success": "Usage export downloaded",
|
||||
"import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}",
|
||||
"import_invalid": "Invalid usage export file",
|
||||
@@ -858,6 +930,20 @@
|
||||
"credential_stats": "Credential Statistics",
|
||||
"credential_name": "Credential",
|
||||
"token_breakdown": "Token Type Breakdown",
|
||||
"request_events_title": "Request Events",
|
||||
"request_events_filter_model": "Model",
|
||||
"request_events_filter_source": "Source",
|
||||
"request_events_filter_auth_index": "Auth Index",
|
||||
"request_events_timestamp": "Timestamp",
|
||||
"request_events_source": "Source",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Result",
|
||||
"request_events_empty_title": "No request events",
|
||||
"request_events_empty_desc": "No request details are available for the selected time range.",
|
||||
"request_events_no_result_title": "No matching events",
|
||||
"request_events_no_result_desc": "Try different filters or clear the current filters.",
|
||||
"request_events_count": "{{count}} events",
|
||||
"request_events_limit_hint": "Showing {{shown}} of {{total}} events to keep rendering responsive.",
|
||||
"input_tokens": "Input Tokens",
|
||||
"output_tokens": "Output Tokens",
|
||||
"last_updated": "Updated"
|
||||
@@ -914,6 +1000,42 @@
|
||||
"show_raw_logs": "Show Raw Logs",
|
||||
"show_raw_logs_hint": "Show original log text for easier multi-line copy",
|
||||
"search_placeholder": "Search logs by content or keyword",
|
||||
"filter_method": "Method",
|
||||
"filter_status": "Status",
|
||||
"filter_path": "Path",
|
||||
"filter_path_empty": "No path candidates",
|
||||
"filter_status_2xx": "2xx",
|
||||
"filter_status_3xx": "3xx",
|
||||
"filter_status_4xx": "4xx",
|
||||
"filter_status_5xx": "5xx",
|
||||
"clear_filters": "Clear Filters",
|
||||
"trace_button": "Trace",
|
||||
"trace_title": "Request Trace (V1)",
|
||||
"trace_notice": "V1 uses heuristic correlation between logs and usage events. Results are estimates, not strict request_id matches.",
|
||||
"trace_log_info": "Log Event",
|
||||
"trace_candidates_title": "Usage Match Candidates",
|
||||
"trace_loading": "Matching usage events...",
|
||||
"trace_no_match": "No candidate matches found.",
|
||||
"trace_usage_load_error": "Failed to load usage details for tracing",
|
||||
"trace_download_request_log": "Download Request Log",
|
||||
"trace_confidence_high": "High",
|
||||
"trace_confidence_medium": "Medium",
|
||||
"trace_confidence_low": "Low",
|
||||
"trace_score": "Score {{score}}",
|
||||
"trace_delta_seconds": "Δt {{seconds}}s",
|
||||
"trace_request_id": "Request ID",
|
||||
"trace_method": "Method",
|
||||
"trace_path": "Path",
|
||||
"trace_status_code": "Status",
|
||||
"trace_latency": "Latency",
|
||||
"trace_ip": "IP",
|
||||
"trace_timestamp": "Timestamp",
|
||||
"trace_message": "Message",
|
||||
"trace_endpoint": "Usage Endpoint",
|
||||
"trace_model": "Model",
|
||||
"trace_source": "Source",
|
||||
"trace_auth_index": "Auth Index",
|
||||
"trace_result": "Result",
|
||||
"search_empty_title": "No matching logs found",
|
||||
"search_empty_desc": "Try a different keyword or clear the filters.",
|
||||
"double_click_copy_hint": "Double-click to copy raw log line",
|
||||
|
||||
+123
-1
@@ -5,6 +5,8 @@
|
||||
"back": "Назад",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить",
|
||||
"leave": "Уйти",
|
||||
"stay": "Остаться",
|
||||
"save": "Сохранить",
|
||||
"delete": "Удалить",
|
||||
"edit": "Редактировать",
|
||||
@@ -32,6 +34,7 @@
|
||||
"base_url": "Адрес",
|
||||
"prefix": "Префикс",
|
||||
"proxy_url": "Прокси",
|
||||
"priority": "Приоритет",
|
||||
"alias": "Псевдоним",
|
||||
"failure": "Сбой",
|
||||
"unknown_error": "Неизвестная ошибка",
|
||||
@@ -47,7 +50,9 @@
|
||||
"custom_headers_value_placeholder": "Значение заголовка",
|
||||
"model_name_placeholder": "Имя модели, напр. claude-3-5-sonnet-20241022",
|
||||
"model_alias_placeholder": "Псевдоним модели (необязательно)",
|
||||
"invalid_provider_index": "Неверный индекс провайдера."
|
||||
"invalid_provider_index": "Неверный индекс провайдера.",
|
||||
"unsaved_changes_title": "Несохранённые изменения",
|
||||
"unsaved_changes_message": "У вас есть несохранённые изменения. Если вы уйдёте, они будут потеряны. Выйти?"
|
||||
},
|
||||
"title": {
|
||||
"main": "Центр управления CLI Proxy API",
|
||||
@@ -131,6 +136,7 @@
|
||||
"no_usage_data": "Данные об использовании отсутствуют",
|
||||
"view_detailed_usage": "Просмотреть детальную статистику",
|
||||
"edit_settings": "Изменить настройки",
|
||||
"routing_strategy": "Стратегия маршрутизации",
|
||||
"available_models": "Доступные модели",
|
||||
"available_models_desc": "Всего моделей от всех провайдеров"
|
||||
},
|
||||
@@ -200,6 +206,23 @@
|
||||
"gemini_base_url_placeholder": "например: https://generativelanguage.googleapis.com",
|
||||
"gemini_add_modal_proxy_label": "URL прокси (необязательно):",
|
||||
"gemini_add_modal_proxy_placeholder": "например: socks5://proxy.example.com:1080",
|
||||
"gemini_models_label": "Пользовательские модели (необязательно):",
|
||||
"gemini_models_hint": "Оставьте пустым, чтобы разрешить все модели, или добавьте записи name[, alias], чтобы ограничить/переименовать их для этого ключа.",
|
||||
"gemini_models_add_btn": "Добавить модель",
|
||||
"gemini_models_fetch_button": "Получить через /v1beta/models",
|
||||
"gemini_models_fetch_title": "Выбор моделей из Gemini /v1beta/models",
|
||||
"gemini_models_fetch_hint": "Вызывает GET /v1beta/models по спецификации Gemini API. По умолчанию отправляется x-goog-api-key из поля API-ключа, объединённый с вашими пользовательскими заголовками.",
|
||||
"gemini_models_fetch_url_label": "URL запроса",
|
||||
"gemini_models_fetch_refresh": "Обновить",
|
||||
"gemini_models_fetch_loading": "Получение моделей из Gemini /v1beta/models...",
|
||||
"gemini_models_fetch_empty": "Модели не получены. Проверьте Base URL, API-ключ или заголовки.",
|
||||
"gemini_models_fetch_error": "Не удалось получить модели Gemini",
|
||||
"gemini_models_fetch_apply": "Добавить выбранные модели",
|
||||
"gemini_models_search_label": "Поиск моделей",
|
||||
"gemini_models_search_placeholder": "Фильтр по имени, псевдониму или описанию",
|
||||
"gemini_models_search_empty": "Нет подходящих моделей. Попробуйте другой запрос.",
|
||||
"gemini_models_fetch_added": "Добавлено новых моделей: {{count}}",
|
||||
"gemini_models_count": "Количество моделей",
|
||||
"gemini_edit_modal_title": "Редактирование API-ключа Gemini",
|
||||
"gemini_edit_modal_key_label": "API-ключ:",
|
||||
"gemini_delete_confirm": "Удалить этот ключ Gemini?",
|
||||
@@ -210,6 +233,8 @@
|
||||
"prefix_label": "Префикс (необязательно):",
|
||||
"prefix_placeholder": "например: team-a",
|
||||
"prefix_hint": "Если задано, обращайтесь к моделям как prefix/<model>, чтобы выбрать эту запись.",
|
||||
"priority_label": "Приоритет (необязательно):",
|
||||
"priority_hint": "Чем больше значение, тем выше приоритет. Оставьте пустым для значения по умолчанию (0).",
|
||||
"config_toggle_label": "Включено",
|
||||
"config_disabled_badge": "Отключено",
|
||||
"codex_title": "Конфигурация Codex API",
|
||||
@@ -224,6 +249,25 @@
|
||||
"codex_add_modal_url_placeholder": "например: https://api.example.com",
|
||||
"codex_add_modal_proxy_label": "URL прокси (необязательно):",
|
||||
"codex_add_modal_proxy_placeholder": "например: socks5://proxy.example.com:1080",
|
||||
"codex_websockets_label": "Websockets",
|
||||
"codex_websockets_hint": "Включает websocket-транспорт Responses API для этого ключа.",
|
||||
"codex_models_label": "Пользовательские модели (необязательно):",
|
||||
"codex_models_hint": "Оставьте пустым, чтобы разрешить все модели, или добавьте записи name[, alias], чтобы ограничить/переименовать их.",
|
||||
"codex_models_add_btn": "Добавить модель",
|
||||
"codex_models_fetch_button": "Получить через /v1/models",
|
||||
"codex_models_fetch_title": "Выбор моделей из Codex /v1/models",
|
||||
"codex_models_fetch_hint": "Вызывает GET /v1/models по спецификации OpenAI. По умолчанию отправляет API-ключ как Bearer (если указан) и объединяет его с пользовательскими заголовками.",
|
||||
"codex_models_fetch_url_label": "URL запроса",
|
||||
"codex_models_fetch_refresh": "Обновить",
|
||||
"codex_models_fetch_loading": "Получение моделей из /v1/models...",
|
||||
"codex_models_fetch_empty": "Модели не получены. Проверьте Base URL, API-ключ или заголовки.",
|
||||
"codex_models_fetch_error": "Не удалось получить модели",
|
||||
"codex_models_fetch_apply": "Добавить выбранные модели",
|
||||
"codex_models_search_label": "Поиск моделей",
|
||||
"codex_models_search_placeholder": "Фильтр по имени, псевдониму или описанию",
|
||||
"codex_models_search_empty": "Модели по запросу не найдены. Попробуйте другой запрос.",
|
||||
"codex_models_fetch_added": "Добавлено новых моделей: {{count}}",
|
||||
"codex_models_count": "Количество моделей",
|
||||
"codex_edit_modal_title": "Редактирование конфигурации Codex API",
|
||||
"codex_edit_modal_key_label": "API-ключ:",
|
||||
"codex_edit_modal_url_label": "Базовый URL (обязательно):",
|
||||
@@ -275,6 +319,21 @@
|
||||
"claude_test_key_required": "Укажите Claude API-ключ или задайте x-api-key в пользовательских заголовках",
|
||||
"claude_test_model_required": "Выберите модель для теста",
|
||||
"claude_test_endpoint_invalid": "Не удалось сформировать корректный endpoint Claude /v1/messages",
|
||||
"claude_cloak_title": "Маскировка запросов (необязательно):",
|
||||
"claude_cloak_toggle_label": "Включить",
|
||||
"claude_cloak_toggle_aria": "Переключить маскировку запросов",
|
||||
"claude_cloak_hint": "Маскировка делает запросы похожими на запросы официального CLI Claude Code для клиентов, которые не являются Claude Code.",
|
||||
"claude_cloak_mode_label": "Режим:",
|
||||
"claude_cloak_mode_hint": "auto: маскировать только если клиент не Claude Code; always: всегда; never: никогда.",
|
||||
"claude_cloak_mode_auto": "Auto (только не-Claude Code)",
|
||||
"claude_cloak_mode_always": "Всегда",
|
||||
"claude_cloak_mode_never": "Никогда",
|
||||
"claude_cloak_strict_label": "Строгий режим:",
|
||||
"claude_cloak_strict_hint": "Если включено, пользовательские system-сообщения удаляются, остаётся только промпт Claude Code.",
|
||||
"claude_cloak_sensitive_words_label": "Чувствительные слова (необязательно):",
|
||||
"claude_cloak_sensitive_words_placeholder": "Через запятую или с новой строки, например internal-project",
|
||||
"claude_cloak_sensitive_words_hint": "Эти слова будут обфусцированы с помощью символов нулевой ширины.",
|
||||
"claude_cloak_sensitive_words_count": "Чувствительные слова",
|
||||
"vertex_title": "Конфигурация Vertex API",
|
||||
"vertex_add_button": "Добавить конфигурацию",
|
||||
"vertex_empty_title": "Конфигурации Vertex отсутствуют",
|
||||
@@ -387,6 +446,15 @@
|
||||
"search_empty_desc": "Попробуйте изменить фильтры или очистить строку поиска.",
|
||||
"file_size": "Размер",
|
||||
"file_modified": "Изменён",
|
||||
"health_status_label": "Состояние",
|
||||
"health_status_healthy": "Нормально",
|
||||
"health_status_warning": "Предупреждение",
|
||||
"health_status_disabled": "Отключено",
|
||||
"health_status_unknown": "Неизвестно",
|
||||
"health_status_no_message": "Нет сообщения о статусе",
|
||||
"last_refresh_label": "Последнее обновление",
|
||||
"refresh_not_available": "Н/Д",
|
||||
"refresh_just_now": "Только что",
|
||||
"download_button": "Скачать",
|
||||
"delete_button": "Удалить",
|
||||
"delete_confirm": "Удалить файл",
|
||||
@@ -810,9 +878,13 @@
|
||||
"range_7h": "Последние 7 часов",
|
||||
"range_24h": "Последние 24 часа",
|
||||
"range_7d": "Последние 7 дней",
|
||||
"filter_all": "Все",
|
||||
"clear_filters": "Сбросить фильтры",
|
||||
"refresh": "Обновить",
|
||||
"export": "Экспорт",
|
||||
"import": "Импорт",
|
||||
"export_csv": "Экспорт CSV",
|
||||
"export_json": "Экспорт JSON",
|
||||
"export_success": "Экспорт использования скачан",
|
||||
"import_success": "Импорт завершён: добавлено {{added}}, пропущено {{skipped}}, всего {{total}}, ошибок {{failed}}",
|
||||
"import_invalid": "Недопустимый файл экспорта использования",
|
||||
@@ -861,6 +933,20 @@
|
||||
"credential_stats": "Статистика учётных данных",
|
||||
"credential_name": "Учётные данные",
|
||||
"token_breakdown": "Распределение типов токенов",
|
||||
"request_events_title": "События запросов",
|
||||
"request_events_filter_model": "Модель",
|
||||
"request_events_filter_source": "Источник",
|
||||
"request_events_filter_auth_index": "Auth Index",
|
||||
"request_events_timestamp": "Время",
|
||||
"request_events_source": "Источник",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Результат",
|
||||
"request_events_empty_title": "События запросов отсутствуют",
|
||||
"request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.",
|
||||
"request_events_no_result_title": "Совпадений не найдено",
|
||||
"request_events_no_result_desc": "Попробуйте другие фильтры или очистите текущие.",
|
||||
"request_events_count": "{{count}} событий",
|
||||
"request_events_limit_hint": "Показано {{shown}} из {{total}} событий для стабильной производительности.",
|
||||
"input_tokens": "Входные токены",
|
||||
"output_tokens": "Выходные токены",
|
||||
"last_updated": "Обновлено"
|
||||
@@ -917,6 +1003,42 @@
|
||||
"show_raw_logs": "Показать исходные журналы",
|
||||
"show_raw_logs_hint": "Показать текст журнала без обработки для удобного копирования в несколько строк",
|
||||
"search_placeholder": "Искать по содержимому или ключевым словам",
|
||||
"filter_method": "Метод",
|
||||
"filter_status": "Статус",
|
||||
"filter_path": "Путь",
|
||||
"filter_path_empty": "Нет доступных путей",
|
||||
"filter_status_2xx": "2xx",
|
||||
"filter_status_3xx": "3xx",
|
||||
"filter_status_4xx": "4xx",
|
||||
"filter_status_5xx": "5xx",
|
||||
"clear_filters": "Очистить фильтры",
|
||||
"trace_button": "Трассировка",
|
||||
"trace_title": "Трассировка запроса (V1)",
|
||||
"trace_notice": "V1 использует эвристическую корреляцию между логами и usage-событиями. Результаты оценочные и не являются точным сопоставлением по request_id.",
|
||||
"trace_log_info": "Событие лога",
|
||||
"trace_candidates_title": "Кандидаты из usage",
|
||||
"trace_loading": "Сопоставление usage-событий...",
|
||||
"trace_no_match": "Подходящие кандидаты не найдены.",
|
||||
"trace_usage_load_error": "Не удалось загрузить usage-детали для трассировки",
|
||||
"trace_download_request_log": "Скачать журнал запроса",
|
||||
"trace_confidence_high": "Высокая",
|
||||
"trace_confidence_medium": "Средняя",
|
||||
"trace_confidence_low": "Низкая",
|
||||
"trace_score": "Оценка {{score}}",
|
||||
"trace_delta_seconds": "Δt {{seconds}}с",
|
||||
"trace_request_id": "Request ID",
|
||||
"trace_method": "Метод",
|
||||
"trace_path": "Путь",
|
||||
"trace_status_code": "Статус",
|
||||
"trace_latency": "Задержка",
|
||||
"trace_ip": "IP",
|
||||
"trace_timestamp": "Время",
|
||||
"trace_message": "Сообщение",
|
||||
"trace_endpoint": "Usage-эндпоинт",
|
||||
"trace_model": "Модель",
|
||||
"trace_source": "Источник",
|
||||
"trace_auth_index": "Auth Index",
|
||||
"trace_result": "Результат",
|
||||
"search_empty_title": "Подходящих журналов не найдено",
|
||||
"search_empty_desc": "Попробуйте другой запрос или сбросьте фильтры.",
|
||||
"double_click_copy_hint": "Дважды нажмите, чтобы скопировать строку журнала",
|
||||
|
||||
+123
-1
@@ -5,6 +5,8 @@
|
||||
"back": "返回",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"leave": "离开",
|
||||
"stay": "继续编辑",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
@@ -32,6 +34,7 @@
|
||||
"base_url": "地址",
|
||||
"prefix": "前缀",
|
||||
"proxy_url": "代理",
|
||||
"priority": "优先级",
|
||||
"alias": "别名",
|
||||
"failure": "失败",
|
||||
"unknown_error": "未知错误",
|
||||
@@ -47,7 +50,9 @@
|
||||
"custom_headers_value_placeholder": "Header 值",
|
||||
"model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022",
|
||||
"model_alias_placeholder": "模型别名 (可选)",
|
||||
"invalid_provider_index": "无效的提供商索引。"
|
||||
"invalid_provider_index": "无效的提供商索引。",
|
||||
"unsaved_changes_title": "未保存的更改",
|
||||
"unsaved_changes_message": "你有未保存的更改,离开后将丢失这些更改。确定要离开吗?"
|
||||
},
|
||||
"title": {
|
||||
"main": "CLI Proxy API Management Center",
|
||||
@@ -131,6 +136,7 @@
|
||||
"no_usage_data": "暂无使用数据",
|
||||
"view_detailed_usage": "查看详细统计",
|
||||
"edit_settings": "编辑设置",
|
||||
"routing_strategy": "路由策略",
|
||||
"available_models": "可用模型",
|
||||
"available_models_desc": "所有提供商的模型总数"
|
||||
},
|
||||
@@ -200,6 +206,23 @@
|
||||
"gemini_base_url_placeholder": "例如: https://generativelanguage.googleapis.com",
|
||||
"gemini_add_modal_proxy_label": "代理 URL (可选):",
|
||||
"gemini_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
|
||||
"gemini_models_label": "自定义模型 (可选):",
|
||||
"gemini_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
|
||||
"gemini_models_add_btn": "添加模型",
|
||||
"gemini_models_fetch_button": "从 /v1beta/models 获取",
|
||||
"gemini_models_fetch_title": "从 Gemini /v1beta/models 选择模型",
|
||||
"gemini_models_fetch_hint": "按 Gemini API 规范请求 GET /v1beta/models;默认附带 x-goog-api-key(来自 API Key 字段)并合并你配置的自定义请求头。",
|
||||
"gemini_models_fetch_url_label": "请求地址",
|
||||
"gemini_models_fetch_refresh": "重新获取",
|
||||
"gemini_models_fetch_loading": "正在从 Gemini /v1beta/models 获取模型列表...",
|
||||
"gemini_models_fetch_empty": "未获取到模型,请检查 Base URL、API Key 或请求头。",
|
||||
"gemini_models_fetch_error": "获取 Gemini 模型失败",
|
||||
"gemini_models_fetch_apply": "添加所选模型",
|
||||
"gemini_models_search_label": "搜索模型",
|
||||
"gemini_models_search_placeholder": "按名称、别名或描述筛选",
|
||||
"gemini_models_search_empty": "没有匹配的模型,请更换关键字试试。",
|
||||
"gemini_models_fetch_added": "已添加 {{count}} 个新模型",
|
||||
"gemini_models_count": "模型数量",
|
||||
"gemini_edit_modal_title": "编辑Gemini API密钥",
|
||||
"gemini_edit_modal_key_label": "API密钥:",
|
||||
"gemini_delete_confirm": "确定要删除这个Gemini密钥吗?",
|
||||
@@ -210,6 +233,8 @@
|
||||
"prefix_label": "前缀 (可选):",
|
||||
"prefix_placeholder": "例如: team-a",
|
||||
"prefix_hint": "设置后可用 prefix/<model> 选择该条目。",
|
||||
"priority_label": "优先级 (可选):",
|
||||
"priority_hint": "数值越大优先级越高;留空表示默认(0)。",
|
||||
"config_toggle_label": "启用",
|
||||
"config_disabled_badge": "已停用",
|
||||
"codex_title": "Codex API 配置",
|
||||
@@ -224,6 +249,25 @@
|
||||
"codex_add_modal_url_placeholder": "例如: https://api.example.com",
|
||||
"codex_add_modal_proxy_label": "代理 URL (可选):",
|
||||
"codex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
|
||||
"codex_websockets_label": "Websockets",
|
||||
"codex_websockets_hint": "开启 Responses API 的 websocket 传输。",
|
||||
"codex_models_label": "自定义模型 (可选):",
|
||||
"codex_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
|
||||
"codex_models_add_btn": "添加模型",
|
||||
"codex_models_fetch_button": "从 /v1/models 获取",
|
||||
"codex_models_fetch_title": "从 /v1/models 选择模型",
|
||||
"codex_models_fetch_hint": "使用上方 Base URL 调用 /v1/models 端点,附带 API Key(Bearer)与自定义请求头。",
|
||||
"codex_models_fetch_url_label": "请求地址",
|
||||
"codex_models_fetch_refresh": "重新获取",
|
||||
"codex_models_fetch_loading": "正在从 /v1/models 获取模型列表...",
|
||||
"codex_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。",
|
||||
"codex_models_fetch_error": "获取模型失败",
|
||||
"codex_models_fetch_apply": "添加所选模型",
|
||||
"codex_models_search_label": "搜索模型",
|
||||
"codex_models_search_placeholder": "按名称、别名或描述筛选",
|
||||
"codex_models_search_empty": "没有匹配的模型,请更换关键字试试。",
|
||||
"codex_models_fetch_added": "已添加 {{count}} 个新模型",
|
||||
"codex_models_count": "模型数量",
|
||||
"codex_edit_modal_title": "编辑Codex API配置",
|
||||
"codex_edit_modal_key_label": "API密钥:",
|
||||
"codex_edit_modal_url_label": "Base URL (必填):",
|
||||
@@ -275,6 +319,21 @@
|
||||
"claude_test_key_required": "请先填写 Claude API Key 或在自定义请求头中设置 x-api-key",
|
||||
"claude_test_model_required": "请选择要测试的模型",
|
||||
"claude_test_endpoint_invalid": "无法构造有效的 Claude /v1/messages 请求地址",
|
||||
"claude_cloak_title": "请求伪装 (可选):",
|
||||
"claude_cloak_toggle_label": "启用",
|
||||
"claude_cloak_toggle_aria": "切换请求伪装",
|
||||
"claude_cloak_hint": "对非 Claude Code 客户端,将请求伪装成 Claude Code CLI 的格式。",
|
||||
"claude_cloak_mode_label": "模式:",
|
||||
"claude_cloak_mode_hint": "auto: 仅对非 Claude Code 客户端伪装;always: 总是伪装;never: 从不伪装。",
|
||||
"claude_cloak_mode_auto": "自动(仅非 Claude Code)",
|
||||
"claude_cloak_mode_always": "总是",
|
||||
"claude_cloak_mode_never": "从不",
|
||||
"claude_cloak_strict_label": "严格模式:",
|
||||
"claude_cloak_strict_hint": "启用后会移除用户 system 消息,仅保留 Claude Code 提示词。",
|
||||
"claude_cloak_sensitive_words_label": "敏感词 (可选):",
|
||||
"claude_cloak_sensitive_words_placeholder": "用逗号或换行分隔,例如: internal-project",
|
||||
"claude_cloak_sensitive_words_hint": "这些词会被插入零宽字符进行混淆。",
|
||||
"claude_cloak_sensitive_words_count": "敏感词",
|
||||
"vertex_title": "Vertex API 配置",
|
||||
"vertex_add_button": "添加配置",
|
||||
"vertex_empty_title": "暂无Vertex配置",
|
||||
@@ -387,6 +446,15 @@
|
||||
"search_empty_desc": "请调整筛选条件或清空搜索关键字再试一次。",
|
||||
"file_size": "大小",
|
||||
"file_modified": "修改时间",
|
||||
"health_status_label": "健康状态",
|
||||
"health_status_healthy": "健康",
|
||||
"health_status_warning": "警告",
|
||||
"health_status_disabled": "已停用",
|
||||
"health_status_unknown": "未知",
|
||||
"health_status_no_message": "暂无状态说明",
|
||||
"last_refresh_label": "最近刷新",
|
||||
"refresh_not_available": "暂无",
|
||||
"refresh_just_now": "刚刚",
|
||||
"download_button": "下载",
|
||||
"delete_button": "删除",
|
||||
"delete_confirm": "确定要删除文件",
|
||||
@@ -807,9 +875,13 @@
|
||||
"range_7h": "最近7小时",
|
||||
"range_24h": "最近24小时",
|
||||
"range_7d": "最近7天",
|
||||
"filter_all": "全部",
|
||||
"clear_filters": "清空筛选",
|
||||
"refresh": "刷新",
|
||||
"export": "导出数据",
|
||||
"import": "导入数据",
|
||||
"export_csv": "导出 CSV",
|
||||
"export_json": "导出 JSON",
|
||||
"export_success": "使用统计已导出",
|
||||
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
|
||||
"import_invalid": "导入文件格式不正确",
|
||||
@@ -858,6 +930,20 @@
|
||||
"credential_stats": "凭证统计",
|
||||
"credential_name": "凭证",
|
||||
"token_breakdown": "Token 类型分布",
|
||||
"request_events_title": "请求事件明细",
|
||||
"request_events_filter_model": "模型",
|
||||
"request_events_filter_source": "来源",
|
||||
"request_events_filter_auth_index": "认证索引",
|
||||
"request_events_timestamp": "时间",
|
||||
"request_events_source": "来源",
|
||||
"request_events_auth_index": "认证索引",
|
||||
"request_events_result": "结果",
|
||||
"request_events_empty_title": "暂无请求事件",
|
||||
"request_events_empty_desc": "当前时间范围内暂无可用的请求明细数据。",
|
||||
"request_events_no_result_title": "没有匹配结果",
|
||||
"request_events_no_result_desc": "请尝试调整筛选条件,或清空当前筛选。",
|
||||
"request_events_count": "{{count}} 条事件",
|
||||
"request_events_limit_hint": "为保证渲染性能,仅展示 {{shown}} / {{total}} 条事件。",
|
||||
"input_tokens": "输入 Tokens",
|
||||
"output_tokens": "输出 Tokens",
|
||||
"last_updated": "更新于"
|
||||
@@ -914,6 +1000,42 @@
|
||||
"show_raw_logs": "显示原始日志",
|
||||
"show_raw_logs_hint": "直接显示原始日志文本,方便多行复制",
|
||||
"search_placeholder": "搜索日志内容或关键字",
|
||||
"filter_method": "请求方法",
|
||||
"filter_status": "状态码",
|
||||
"filter_path": "路径",
|
||||
"filter_path_empty": "暂无可筛选路径",
|
||||
"filter_status_2xx": "2xx",
|
||||
"filter_status_3xx": "3xx",
|
||||
"filter_status_4xx": "4xx",
|
||||
"filter_status_5xx": "5xx",
|
||||
"clear_filters": "清空筛选",
|
||||
"trace_button": "请求追踪",
|
||||
"trace_title": "请求追踪 (V1)",
|
||||
"trace_notice": "V1 通过日志与 usage 事件做启发式关联,结果为估算,不是严格 request_id 级精准匹配。",
|
||||
"trace_log_info": "日志事件",
|
||||
"trace_candidates_title": "Usage 候选匹配",
|
||||
"trace_loading": "正在匹配 usage 事件...",
|
||||
"trace_no_match": "未找到候选匹配结果。",
|
||||
"trace_usage_load_error": "加载请求追踪所需 usage 明细失败",
|
||||
"trace_download_request_log": "下载请求日志",
|
||||
"trace_confidence_high": "高",
|
||||
"trace_confidence_medium": "中",
|
||||
"trace_confidence_low": "低",
|
||||
"trace_score": "分数 {{score}}",
|
||||
"trace_delta_seconds": "时间差 {{seconds}} 秒",
|
||||
"trace_request_id": "请求 ID",
|
||||
"trace_method": "请求方法",
|
||||
"trace_path": "路径",
|
||||
"trace_status_code": "状态码",
|
||||
"trace_latency": "耗时",
|
||||
"trace_ip": "IP",
|
||||
"trace_timestamp": "时间",
|
||||
"trace_message": "消息",
|
||||
"trace_endpoint": "Usage 接口",
|
||||
"trace_model": "模型",
|
||||
"trace_source": "来源",
|
||||
"trace_auth_index": "认证索引",
|
||||
"trace_result": "结果",
|
||||
"search_empty_title": "未找到匹配的日志",
|
||||
"search_empty_desc": "尝试更换关键字或清空筛选条件。",
|
||||
"double_click_copy_hint": "双击复制日志原文",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { ampcodeApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
@@ -24,6 +25,23 @@ const getErrorMessage = (err: unknown) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeMappingEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
(entries ?? []).reduce<Array<{ from: string; to: string }>>((acc, entry) => {
|
||||
const from = String(entry?.name ?? '').trim();
|
||||
const to = String(entry?.alias ?? '').trim();
|
||||
if (!from && !to) return acc;
|
||||
acc.push({ from, to });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const buildAmpcodeSignature = (form: AmpcodeFormState) =>
|
||||
JSON.stringify({
|
||||
upstreamUrl: String(form.upstreamUrl ?? '').trim(),
|
||||
upstreamApiKey: String(form.upstreamApiKey ?? '').trim(),
|
||||
forceModelMappings: Boolean(form.forceModelMappings),
|
||||
modelMappings: normalizeMappingEntries(form.mappingEntries),
|
||||
});
|
||||
|
||||
export function AiProvidersAmpcodeEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -42,6 +60,9 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
const [mappingsDirty, setMappingsDirty] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [baselineSignature, setBaselineSignature] = useState(() =>
|
||||
buildAmpcodeSignature(buildAmpcodeFormState(null))
|
||||
);
|
||||
const initializedRef = useRef(false);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
@@ -83,7 +104,9 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
setLoaded(false);
|
||||
setMappingsDirty(false);
|
||||
setError('');
|
||||
setForm(buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null));
|
||||
const initialForm = buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null);
|
||||
setForm(initialForm);
|
||||
setBaselineSignature(buildAmpcodeSignature(initialForm));
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -93,7 +116,9 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
setLoaded(true);
|
||||
updateConfigValue('ampcode', ampcode);
|
||||
clearCache('ampcode');
|
||||
setForm(buildAmpcodeFormState(ampcode));
|
||||
const nextForm = buildAmpcodeFormState(ampcode);
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildAmpcodeSignature(nextForm));
|
||||
} catch (err: unknown) {
|
||||
if (!mountedRef.current) return;
|
||||
setError(getErrorMessage(err) || t('notification.refresh_failed'));
|
||||
@@ -105,6 +130,23 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
})();
|
||||
}, [clearCache, t, updateConfigValue]);
|
||||
|
||||
const currentSignature = useMemo(() => buildAmpcodeSignature(form), [form]);
|
||||
const isDirty = baselineSignature !== currentSignature;
|
||||
const canGuard = !loading && !saving;
|
||||
|
||||
const { allowNextNavigation } = useUnsavedChangesGuard({
|
||||
enabled: canGuard,
|
||||
shouldBlock: ({ currentLocation, nextLocation }) =>
|
||||
isDirty && currentLocation.pathname !== nextLocation.pathname,
|
||||
dialog: {
|
||||
title: t('common.unsaved_changes_title'),
|
||||
message: t('common.unsaved_changes_message'),
|
||||
confirmText: t('common.leave'),
|
||||
cancelText: t('common.stay'),
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
|
||||
const clearAmpcodeUpstreamApiKey = async () => {
|
||||
showConfirmation({
|
||||
title: t('ai_providers.ampcode_clear_upstream_api_key_title', {
|
||||
@@ -192,6 +234,8 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_updated'), 'success');
|
||||
allowNextNavigation();
|
||||
setBaselineSignature(buildAmpcodeSignature(form));
|
||||
handleBack();
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
|
||||
@@ -2,12 +2,13 @@ import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { useAuthStore, useClaudeEditDraftStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import type { ModelEntry, ProviderFormState } from '@/components/providers/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
|
||||
@@ -39,6 +40,7 @@ export type ClaudeEditOutletContext = {
|
||||
|
||||
const buildEmptyForm = (): ProviderFormState => ({
|
||||
apiKey: '',
|
||||
priority: undefined,
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
@@ -61,6 +63,59 @@ const getErrorMessage = (err: unknown) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeClaudeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = String(entry?.name ?? '').trim();
|
||||
let alias = String(entry?.alias ?? '').trim();
|
||||
if (name) {
|
||||
alias = alias || name;
|
||||
}
|
||||
if (!name && !alias) return acc;
|
||||
acc.push({ name, alias });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const normalizeCloakConfig = (cloak: ProviderFormState['cloak']) => {
|
||||
if (!cloak) return null;
|
||||
const mode = String(cloak.mode ?? '').trim().toLowerCase() || 'auto';
|
||||
const strictMode = Boolean(cloak.strictMode);
|
||||
const sensitiveWords = Array.isArray(cloak.sensitiveWords)
|
||||
? cloak.sensitiveWords.map((word) => String(word ?? '').trim()).filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
mode,
|
||||
strictMode,
|
||||
sensitiveWords: sensitiveWords.length ? sensitiveWords : null,
|
||||
};
|
||||
};
|
||||
|
||||
const buildClaudeSignature = (form: ProviderFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
headers: normalizeHeaderEntries(form.headers),
|
||||
models: normalizeClaudeModelEntries(form.modelEntries),
|
||||
excludedModels: parseExcludedModels(form.excludedText ?? ''),
|
||||
cloak: normalizeCloakConfig(form.cloak),
|
||||
});
|
||||
|
||||
export function AiProvidersClaudeEditLayout() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -84,10 +139,55 @@ export function AiProvidersClaudeEditLayout() {
|
||||
const [configs, setConfigs] = useState<ProviderKeyConfig[]>(() => config?.claudeApiKeys ?? []);
|
||||
const [loading, setLoading] = useState(() => !isCacheValid('claude-api-key'));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
|
||||
const [testModel, setTestModel] = useState('');
|
||||
const [testStatus, setTestStatus] = useState<TestStatus>('idle');
|
||||
const [testMessage, setTestMessage] = useState('');
|
||||
|
||||
const draftKey = useMemo(() => {
|
||||
if (invalidIndexParam) return `claude:invalid:${params.index ?? 'unknown'}`;
|
||||
if (editIndex === null) return 'claude:new';
|
||||
return `claude:${editIndex}`;
|
||||
}, [editIndex, invalidIndexParam, params.index]);
|
||||
|
||||
const draft = useClaudeEditDraftStore((state) => state.drafts[draftKey]);
|
||||
const acquireDraft = useClaudeEditDraftStore((state) => state.acquireDraft);
|
||||
const releaseDraft = useClaudeEditDraftStore((state) => state.releaseDraft);
|
||||
const initDraft = useClaudeEditDraftStore((state) => state.initDraft);
|
||||
const setDraftBaselineSignature = useClaudeEditDraftStore((state) => state.setDraftBaselineSignature);
|
||||
const setDraftForm = useClaudeEditDraftStore((state) => state.setDraftForm);
|
||||
const setDraftTestModel = useClaudeEditDraftStore((state) => state.setDraftTestModel);
|
||||
const setDraftTestStatus = useClaudeEditDraftStore((state) => state.setDraftTestStatus);
|
||||
const setDraftTestMessage = useClaudeEditDraftStore((state) => state.setDraftTestMessage);
|
||||
|
||||
const form = draft?.form ?? buildEmptyForm();
|
||||
const testModel = draft?.testModel ?? '';
|
||||
const testStatus = draft?.testStatus ?? 'idle';
|
||||
const testMessage = draft?.testMessage ?? '';
|
||||
|
||||
const setForm: Dispatch<SetStateAction<ProviderFormState>> = useCallback(
|
||||
(action) => {
|
||||
setDraftForm(draftKey, action);
|
||||
},
|
||||
[draftKey, setDraftForm]
|
||||
);
|
||||
|
||||
const setTestModel: Dispatch<SetStateAction<string>> = useCallback(
|
||||
(action) => {
|
||||
setDraftTestModel(draftKey, action);
|
||||
},
|
||||
[draftKey, setDraftTestModel]
|
||||
);
|
||||
|
||||
const setTestStatus: Dispatch<SetStateAction<TestStatus>> = useCallback(
|
||||
(action) => {
|
||||
setDraftTestStatus(draftKey, action);
|
||||
},
|
||||
[draftKey, setDraftTestStatus]
|
||||
);
|
||||
|
||||
const setTestMessage: Dispatch<SetStateAction<string>> = useCallback(
|
||||
(action) => {
|
||||
setDraftTestMessage(draftKey, action);
|
||||
},
|
||||
[draftKey, setDraftTestMessage]
|
||||
);
|
||||
|
||||
const initialData = useMemo(() => {
|
||||
if (editIndex === null) return undefined;
|
||||
@@ -101,6 +201,11 @@ export function AiProvidersClaudeEditLayout() {
|
||||
[form.modelEntries]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
acquireDraft(draftKey);
|
||||
return () => releaseDraft(draftKey);
|
||||
}, [acquireDraft, draftKey, releaseDraft]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
const state = location.state as LocationState;
|
||||
if (state?.fromAiProviders) {
|
||||
@@ -139,22 +244,68 @@ export function AiProvidersClaudeEditLayout() {
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (draft?.initialized) return;
|
||||
|
||||
if (initialData) {
|
||||
setForm({
|
||||
const seededForm: ProviderFormState = {
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
modelEntries: modelsToEntries(initialData.models),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
};
|
||||
const available = seededForm.modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
|
||||
const baselineSignature = buildClaudeSignature(seededForm);
|
||||
initDraft(draftKey, {
|
||||
baselineSignature,
|
||||
form: seededForm,
|
||||
testModel: available[0] || '',
|
||||
testStatus: 'idle',
|
||||
testMessage: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setForm(buildEmptyForm());
|
||||
}, [initialData, loading]);
|
||||
const emptyForm = buildEmptyForm();
|
||||
initDraft(draftKey, {
|
||||
baselineSignature: buildClaudeSignature(emptyForm),
|
||||
form: emptyForm,
|
||||
testModel: '',
|
||||
testStatus: 'idle',
|
||||
testMessage: '',
|
||||
});
|
||||
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
|
||||
|
||||
const resolvedLoading = !draft?.initialized;
|
||||
const currentSignature = useMemo(() => buildClaudeSignature(form), [form]);
|
||||
const baselineSignature = draft?.baselineSignature ?? '';
|
||||
const isDirty = Boolean(draft?.initialized) && baselineSignature !== currentSignature;
|
||||
const editorRootPath = useMemo(() => {
|
||||
if (hasIndexParam) {
|
||||
return `/ai-providers/claude/${params.index ?? ''}`;
|
||||
}
|
||||
return '/ai-providers/claude/new';
|
||||
}, [hasIndexParam, params.index]);
|
||||
const canGuard = !resolvedLoading && !saving && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
const { allowNextNavigation } = useUnsavedChangesGuard({
|
||||
enabled: canGuard,
|
||||
shouldBlock: ({ nextLocation }) => {
|
||||
const nextPath = nextLocation.pathname;
|
||||
const isWithinRoot =
|
||||
nextPath === editorRootPath || nextPath.startsWith(`${editorRootPath}/`);
|
||||
return isDirty && !isWithinRoot;
|
||||
},
|
||||
dialog: {
|
||||
title: t('common.unsaved_changes_title'),
|
||||
message: t('common.unsaved_changes_message'),
|
||||
confirmText: t('common.leave'),
|
||||
cancelText: t('common.stay'),
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (resolvedLoading) return;
|
||||
|
||||
if (availableModels.length === 0) {
|
||||
if (testModel) {
|
||||
@@ -170,7 +321,7 @@ export function AiProvidersClaudeEditLayout() {
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}
|
||||
}, [availableModels, loading, testModel]);
|
||||
}, [availableModels, resolvedLoading, setTestMessage, setTestModel, setTestStatus, testModel]);
|
||||
|
||||
const mergeDiscoveredModels = useCallback(
|
||||
(selectedModels: ModelInfo[]) => {
|
||||
@@ -203,17 +354,19 @@ export function AiProvidersClaudeEditLayout() {
|
||||
showNotification(t('ai_providers.claude_models_fetch_added', { count: addedCount }), 'success');
|
||||
}
|
||||
},
|
||||
[showNotification, t]
|
||||
[setForm, showNotification, t]
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
const canSave =
|
||||
!disableControls && !saving && !resolvedLoading && !invalidIndexParam && !invalidIndex;
|
||||
if (!canSave) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: ProviderKeyConfig = {
|
||||
apiKey: form.apiKey.trim(),
|
||||
priority: form.priority !== undefined ? Math.trunc(form.priority) : undefined,
|
||||
prefix: form.prefix?.trim() || undefined,
|
||||
baseUrl: (form.baseUrl ?? '').trim() || undefined,
|
||||
proxyUrl: form.proxyUrl?.trim() || undefined,
|
||||
@@ -227,6 +380,7 @@ export function AiProvidersClaudeEditLayout() {
|
||||
})
|
||||
.filter(Boolean) as ProviderKeyConfig['models'],
|
||||
excludedModels: parseExcludedModels(form.excludedText),
|
||||
cloak: form.cloak,
|
||||
};
|
||||
|
||||
const nextList =
|
||||
@@ -242,6 +396,8 @@ export function AiProvidersClaudeEditLayout() {
|
||||
editIndex !== null ? t('notification.claude_config_updated') : t('notification.claude_config_added'),
|
||||
'success'
|
||||
);
|
||||
allowNextNavigation();
|
||||
setDraftBaselineSignature(draftKey, buildClaudeSignature(form));
|
||||
handleBack();
|
||||
} catch (err: unknown) {
|
||||
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
|
||||
@@ -249,15 +405,18 @@ export function AiProvidersClaudeEditLayout() {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
allowNextNavigation,
|
||||
clearCache,
|
||||
configs,
|
||||
draftKey,
|
||||
disableControls,
|
||||
editIndex,
|
||||
form,
|
||||
handleBack,
|
||||
invalidIndex,
|
||||
invalidIndexParam,
|
||||
loading,
|
||||
resolvedLoading,
|
||||
setDraftBaselineSignature,
|
||||
saving,
|
||||
showNotification,
|
||||
t,
|
||||
@@ -272,7 +431,7 @@ export function AiProvidersClaudeEditLayout() {
|
||||
invalidIndexParam,
|
||||
invalidIndex,
|
||||
disableControls,
|
||||
loading,
|
||||
loading: resolvedLoading,
|
||||
saving,
|
||||
form,
|
||||
setForm,
|
||||
|
||||
@@ -7,12 +7,13 @@ import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
import { buildClaudeMessagesEndpoint } from '@/components/providers/utils';
|
||||
import { buildClaudeMessagesEndpoint, parseExcludedModels } from '@/components/providers/utils';
|
||||
import type { ClaudeEditOutletContext } from './AiProvidersClaudeEditLayout';
|
||||
import styles from './AiProvidersPage.module.scss';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
@@ -99,6 +100,23 @@ export function AiProvidersClaudeEditPage() {
|
||||
}, []);
|
||||
}, [form.modelEntries]);
|
||||
|
||||
const cloakModeOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'auto', label: t('ai_providers.claude_cloak_mode_auto') },
|
||||
{ value: 'always', label: t('ai_providers.claude_cloak_mode_always') },
|
||||
{ value: 'never', label: t('ai_providers.claude_cloak_mode_never') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const resolvedCloakMode = useMemo(() => {
|
||||
const mode = (form.cloak?.mode ?? '').trim().toLowerCase();
|
||||
if (!mode) return 'auto';
|
||||
if (mode === 'provider') return 'auto';
|
||||
if (mode === 'auto' || mode === 'always' || mode === 'never') return mode;
|
||||
return 'auto';
|
||||
}, [form.cloak?.mode]);
|
||||
|
||||
const connectivityConfigSignature = useMemo(() => {
|
||||
const headersSignature = form.headers
|
||||
.map((entry) => `${entry.key.trim()}:${entry.value.trim()}`)
|
||||
@@ -267,6 +285,22 @@ export function AiProvidersClaudeEditPage() {
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
disabled={saving || disableControls || isTesting}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.priority_label')}
|
||||
hint={t('ai_providers.priority_hint')}
|
||||
type="number"
|
||||
step={1}
|
||||
value={form.priority ?? ''}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const parsed = raw.trim() === '' ? undefined : Number(raw);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
priority: parsed !== undefined && Number.isFinite(parsed) ? parsed : undefined,
|
||||
}));
|
||||
}}
|
||||
disabled={saving || disableControls || isTesting}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
@@ -418,6 +452,98 @@ export function AiProvidersClaudeEditPage() {
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.modelConfigSection}>
|
||||
<div className={styles.modelConfigHeader}>
|
||||
<label className={styles.modelConfigTitle}>{t('ai_providers.claude_cloak_title')}</label>
|
||||
<div className={styles.modelConfigToolbar}>
|
||||
<ToggleSwitch
|
||||
checked={Boolean(form.cloak)}
|
||||
onChange={(enabled) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
cloak: enabled
|
||||
? {
|
||||
mode: (prev.cloak?.mode ?? 'auto').trim() || 'auto',
|
||||
strictMode: prev.cloak?.strictMode ?? false,
|
||||
sensitiveWords: prev.cloak?.sensitiveWords ?? [],
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
}
|
||||
disabled={saving || disableControls || isTesting}
|
||||
ariaLabel={t('ai_providers.claude_cloak_toggle_aria')}
|
||||
label={t('ai_providers.claude_cloak_toggle_label')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.sectionHint}>{t('ai_providers.claude_cloak_hint')}</div>
|
||||
|
||||
{form.cloak ? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.claude_cloak_mode_label')}</label>
|
||||
<Select
|
||||
value={resolvedCloakMode}
|
||||
options={cloakModeOptions}
|
||||
onChange={(value) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
cloak: {
|
||||
...(prev.cloak ?? {}),
|
||||
mode: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
ariaLabel={t('ai_providers.claude_cloak_mode_label')}
|
||||
disabled={saving || disableControls || isTesting}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.claude_cloak_mode_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.claude_cloak_strict_label')}</label>
|
||||
<ToggleSwitch
|
||||
checked={Boolean(form.cloak.strictMode)}
|
||||
onChange={(value) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
cloak: {
|
||||
...(prev.cloak ?? {}),
|
||||
strictMode: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
disabled={saving || disableControls || isTesting}
|
||||
ariaLabel={t('ai_providers.claude_cloak_strict_label')}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.claude_cloak_strict_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.claude_cloak_sensitive_words_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.claude_cloak_sensitive_words_placeholder')}
|
||||
value={(form.cloak.sensitiveWords ?? []).join('\n')}
|
||||
onChange={(e) => {
|
||||
const nextWords = parseExcludedModels(e.target.value);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
cloak: {
|
||||
...(prev.cloak ?? {}),
|
||||
sensitiveWords: nextWords.length ? nextWords : undefined,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
rows={3}
|
||||
disabled={saving || disableControls || isTesting}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.claude_cloak_sensitive_words_hint')}</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { modelsApi, providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { entriesToModels } from '@/components/ui/modelInputListUtils';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
import type { ProviderFormState } from '@/components/providers';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
import styles from './AiProvidersPage.module.scss';
|
||||
|
||||
type LocationState = { fromAiProviders?: boolean } | null;
|
||||
|
||||
const buildEmptyForm = (): ProviderFormState => ({
|
||||
apiKey: '',
|
||||
priority: undefined,
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
websockets: false,
|
||||
proxyUrl: '',
|
||||
headers: [],
|
||||
models: [],
|
||||
@@ -36,6 +44,51 @@ const parseIndexParam = (value: string | undefined) => {
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = String(entry?.name ?? '').trim();
|
||||
let alias = String(entry?.alias ?? '').trim();
|
||||
if (name && alias === name) {
|
||||
alias = '';
|
||||
}
|
||||
if (!name && !alias) return acc;
|
||||
acc.push({ name, alias });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const buildCodexSignature = (form: ProviderFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
websockets: Boolean(form.websockets),
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
headers: normalizeHeaderEntries(form.headers),
|
||||
models: normalizeModelEntries(form.modelEntries),
|
||||
excludedModels: parseExcludedModels(form.excludedText ?? ''),
|
||||
});
|
||||
|
||||
export function AiProvidersCodexEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -55,6 +108,16 @@ export function AiProvidersCodexEditPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
|
||||
const [baselineSignature, setBaselineSignature] = useState(() => buildCodexSignature(buildEmptyForm()));
|
||||
|
||||
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
|
||||
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
|
||||
const [discoveredModels, setDiscoveredModels] = useState<ModelInfo[]>([]);
|
||||
const [modelDiscoveryFetching, setModelDiscoveryFetching] = useState(false);
|
||||
const [modelDiscoveryError, setModelDiscoveryError] = useState('');
|
||||
const [modelDiscoverySearch, setModelDiscoverySearch] = useState('');
|
||||
const [modelDiscoverySelected, setModelDiscoverySelected] = useState<Set<string>>(new Set());
|
||||
const autoFetchSignatureRef = useRef<string>('');
|
||||
|
||||
const hasIndexParam = typeof params.index === 'string';
|
||||
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||
@@ -120,22 +183,168 @@ export function AiProvidersCodexEditPage() {
|
||||
if (loading) return;
|
||||
|
||||
if (initialData) {
|
||||
setForm({
|
||||
const nextForm: ProviderFormState = {
|
||||
...initialData,
|
||||
websockets: Boolean(initialData.websockets),
|
||||
headers: headersToEntries(initialData.headers),
|
||||
modelEntries: (initialData.models || []).map((model) => ({
|
||||
name: model.name,
|
||||
alias: model.alias ?? '',
|
||||
})),
|
||||
modelEntries: modelsToEntries(initialData.models),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
};
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildCodexSignature(nextForm));
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
const nextForm = buildEmptyForm();
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildCodexSignature(nextForm));
|
||||
}, [initialData, loading]);
|
||||
|
||||
const currentSignature = useMemo(() => buildCodexSignature(form), [form]);
|
||||
const isDirty = baselineSignature !== currentSignature;
|
||||
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
const { allowNextNavigation } = useUnsavedChangesGuard({
|
||||
enabled: canGuard,
|
||||
shouldBlock: ({ currentLocation, nextLocation }) =>
|
||||
isDirty && currentLocation.pathname !== nextLocation.pathname,
|
||||
dialog: {
|
||||
title: t('common.unsaved_changes_title'),
|
||||
message: t('common.unsaved_changes_message'),
|
||||
confirmText: t('common.leave'),
|
||||
cancelText: t('common.stay'),
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
|
||||
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
const discoveredModelsFiltered = useMemo(() => {
|
||||
const filter = modelDiscoverySearch.trim().toLowerCase();
|
||||
if (!filter) return discoveredModels;
|
||||
return discoveredModels.filter((model) => {
|
||||
const name = (model.name || '').toLowerCase();
|
||||
const alias = (model.alias || '').toLowerCase();
|
||||
const description = (model.description || '').toLowerCase();
|
||||
return name.includes(filter) || alias.includes(filter) || description.includes(filter);
|
||||
});
|
||||
}, [discoveredModels, modelDiscoverySearch]);
|
||||
|
||||
const mergeDiscoveredModels = useCallback(
|
||||
(selectedModels: ModelInfo[]) => {
|
||||
if (!selectedModels.length) return;
|
||||
|
||||
let addedCount = 0;
|
||||
setForm((prev) => {
|
||||
const mergedMap = new Map<string, { name: string; alias: string }>();
|
||||
prev.modelEntries.forEach((entry) => {
|
||||
const name = entry.name.trim();
|
||||
if (!name) return;
|
||||
mergedMap.set(name.toLowerCase(), { name, alias: entry.alias?.trim() || '' });
|
||||
});
|
||||
|
||||
selectedModels.forEach((model) => {
|
||||
const name = String(model.name ?? '').trim();
|
||||
if (!name) return;
|
||||
const key = name.toLowerCase();
|
||||
if (mergedMap.has(key)) return;
|
||||
mergedMap.set(key, { name, alias: model.alias ?? '' });
|
||||
addedCount += 1;
|
||||
});
|
||||
|
||||
const mergedEntries = Array.from(mergedMap.values());
|
||||
return {
|
||||
...prev,
|
||||
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
|
||||
};
|
||||
});
|
||||
|
||||
if (addedCount > 0) {
|
||||
showNotification(t('ai_providers.codex_models_fetch_added', { count: addedCount }), 'success');
|
||||
}
|
||||
},
|
||||
[setForm, showNotification, t]
|
||||
);
|
||||
|
||||
const fetchCodexModelDiscovery = useCallback(async () => {
|
||||
setModelDiscoveryFetching(true);
|
||||
setModelDiscoveryError('');
|
||||
|
||||
try {
|
||||
const headerObject = buildHeaderObject(form.headers);
|
||||
const hasCustomAuthorization = Object.keys(headerObject).some(
|
||||
(key) => key.toLowerCase() === 'authorization'
|
||||
);
|
||||
const apiKey = form.apiKey.trim() || undefined;
|
||||
const list = await modelsApi.fetchV1ModelsViaApiCall(
|
||||
form.baseUrl ?? '',
|
||||
hasCustomAuthorization ? undefined : apiKey,
|
||||
headerObject
|
||||
);
|
||||
setDiscoveredModels(list);
|
||||
} catch (err: unknown) {
|
||||
setDiscoveredModels([]);
|
||||
const message = getErrorMessage(err);
|
||||
setModelDiscoveryError(`${t('ai_providers.codex_models_fetch_error')}: ${message}`);
|
||||
} finally {
|
||||
setModelDiscoveryFetching(false);
|
||||
}
|
||||
}, [form.apiKey, form.baseUrl, form.headers, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelDiscoveryOpen) {
|
||||
autoFetchSignatureRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEndpoint = modelsApi.buildV1ModelsEndpoint(form.baseUrl ?? '');
|
||||
setModelDiscoveryEndpoint(nextEndpoint);
|
||||
setDiscoveredModels([]);
|
||||
setModelDiscoverySearch('');
|
||||
setModelDiscoverySelected(new Set());
|
||||
setModelDiscoveryError('');
|
||||
|
||||
if (!nextEndpoint) return;
|
||||
|
||||
const headerObject = buildHeaderObject(form.headers);
|
||||
const hasCustomAuthorization = Object.keys(headerObject).some(
|
||||
(key) => key.toLowerCase() === 'authorization'
|
||||
);
|
||||
const hasApiKeyField = Boolean(form.apiKey.trim());
|
||||
const canAutoFetch = hasApiKeyField || hasCustomAuthorization;
|
||||
|
||||
if (!canAutoFetch) return;
|
||||
|
||||
const headerSignature = Object.entries(headerObject)
|
||||
.sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase()))
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join('|');
|
||||
const signature = `${nextEndpoint}||${form.apiKey.trim()}||${headerSignature}`;
|
||||
if (autoFetchSignatureRef.current === signature) return;
|
||||
autoFetchSignatureRef.current = signature;
|
||||
|
||||
void fetchCodexModelDiscovery();
|
||||
}, [fetchCodexModelDiscovery, form.apiKey, form.baseUrl, form.headers, modelDiscoveryOpen]);
|
||||
|
||||
const toggleModelDiscoverySelection = (name: string) => {
|
||||
setModelDiscoverySelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleApplyDiscoveredModels = () => {
|
||||
const selectedModels = discoveredModels.filter((model) => modelDiscoverySelected.has(model.name));
|
||||
if (selectedModels.length) {
|
||||
mergeDiscoveredModels(selectedModels);
|
||||
}
|
||||
setModelDiscoveryOpen(false);
|
||||
};
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
|
||||
@@ -151,8 +360,10 @@ export function AiProvidersCodexEditPage() {
|
||||
try {
|
||||
const payload: ProviderKeyConfig = {
|
||||
apiKey: form.apiKey.trim(),
|
||||
priority: form.priority !== undefined ? Math.trunc(form.priority) : undefined,
|
||||
prefix: form.prefix?.trim() || undefined,
|
||||
baseUrl,
|
||||
websockets: Boolean(form.websockets),
|
||||
proxyUrl: form.proxyUrl?.trim() || undefined,
|
||||
headers: buildHeaderObject(form.headers),
|
||||
models: entriesToModels(form.modelEntries),
|
||||
@@ -171,6 +382,8 @@ export function AiProvidersCodexEditPage() {
|
||||
editIndex !== null ? t('notification.codex_config_updated') : t('notification.codex_config_added'),
|
||||
'success'
|
||||
);
|
||||
allowNextNavigation();
|
||||
setBaselineSignature(buildCodexSignature(form));
|
||||
handleBack();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
@@ -180,6 +393,7 @@ export function AiProvidersCodexEditPage() {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
allowNextNavigation,
|
||||
canSave,
|
||||
clearCache,
|
||||
configs,
|
||||
@@ -191,6 +405,15 @@ export function AiProvidersCodexEditPage() {
|
||||
updateConfigValue,
|
||||
]);
|
||||
|
||||
const canOpenModelDiscovery =
|
||||
!disableControls &&
|
||||
!saving &&
|
||||
!loading &&
|
||||
!invalidIndexParam &&
|
||||
!invalidIndex &&
|
||||
Boolean((form.baseUrl ?? '').trim());
|
||||
const canApplyModelDiscovery = !disableControls && !saving && !modelDiscoveryFetching;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
ref={swipeRef}
|
||||
@@ -219,6 +442,22 @@ export function AiProvidersCodexEditPage() {
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.priority_label')}
|
||||
hint={t('ai_providers.priority_hint')}
|
||||
type="number"
|
||||
step={1}
|
||||
value={form.priority ?? ''}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const parsed = raw.trim() === '' ? undefined : Number(raw);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
priority: parsed !== undefined && Number.isFinite(parsed) ? parsed : undefined,
|
||||
}));
|
||||
}}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
@@ -233,6 +472,16 @@ export function AiProvidersCodexEditPage() {
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.codex_websockets_label')}</label>
|
||||
<ToggleSwitch
|
||||
checked={Boolean(form.websockets)}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, websockets: value }))}
|
||||
disabled={disableControls || saving}
|
||||
ariaLabel={t('ai_providers.codex_websockets_label')}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.codex_websockets_hint')}</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t('ai_providers.codex_add_modal_proxy_label')}
|
||||
value={form.proxyUrl ?? ''}
|
||||
@@ -249,6 +498,51 @@ export function AiProvidersCodexEditPage() {
|
||||
removeButtonAriaLabel={t('common.delete')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
|
||||
<div className={styles.modelConfigSection}>
|
||||
<div className={styles.modelConfigHeader}>
|
||||
<label className={styles.modelConfigTitle}>{t('ai_providers.codex_models_label')}</label>
|
||||
<div className={styles.modelConfigToolbar}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelEntries: [...prev.modelEntries, { name: '', alias: '' }],
|
||||
}))
|
||||
}
|
||||
disabled={disableControls || saving}
|
||||
>
|
||||
{t('ai_providers.codex_models_add_btn')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setModelDiscoveryOpen(true)}
|
||||
disabled={!canOpenModelDiscovery}
|
||||
>
|
||||
{t('ai_providers.codex_models_fetch_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_hint')}</div>
|
||||
|
||||
<ModelInputList
|
||||
entries={form.modelEntries}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||
namePlaceholder={t('common.model_name_placeholder')}
|
||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||
disabled={disableControls || saving}
|
||||
hideAddButton
|
||||
className={styles.modelInputList}
|
||||
rowClassName={styles.modelInputRow}
|
||||
inputClassName={styles.modelInputField}
|
||||
removeButtonClassName={styles.modelRowRemoveButton}
|
||||
removeButtonTitle={t('common.delete')}
|
||||
removeButtonAriaLabel={t('common.delete')}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
@@ -261,6 +555,103 @@ export function AiProvidersCodexEditPage() {
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={modelDiscoveryOpen}
|
||||
title={t('ai_providers.codex_models_fetch_title')}
|
||||
onClose={() => setModelDiscoveryOpen(false)}
|
||||
width={720}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setModelDiscoveryOpen(false)}
|
||||
disabled={modelDiscoveryFetching}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApplyDiscoveredModels}
|
||||
disabled={!canApplyModelDiscovery}
|
||||
>
|
||||
{t('ai_providers.codex_models_fetch_apply')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.openaiModelsContent}>
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_hint')}</div>
|
||||
<div className={styles.openaiModelsEndpointSection}>
|
||||
<label className={styles.openaiModelsEndpointLabel}>
|
||||
{t('ai_providers.codex_models_fetch_url_label')}
|
||||
</label>
|
||||
<div className={styles.openaiModelsEndpointControls}>
|
||||
<input
|
||||
className={`input ${styles.openaiModelsEndpointInput}`}
|
||||
readOnly
|
||||
value={modelDiscoveryEndpoint}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void fetchCodexModelDiscovery()}
|
||||
loading={modelDiscoveryFetching}
|
||||
disabled={disableControls || saving}
|
||||
>
|
||||
{t('ai_providers.codex_models_fetch_refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t('ai_providers.codex_models_search_label')}
|
||||
placeholder={t('ai_providers.codex_models_search_placeholder')}
|
||||
value={modelDiscoverySearch}
|
||||
onChange={(e) => setModelDiscoverySearch(e.target.value)}
|
||||
disabled={modelDiscoveryFetching}
|
||||
/>
|
||||
{modelDiscoveryError && <div className="error-box">{modelDiscoveryError}</div>}
|
||||
{modelDiscoveryFetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_loading')}</div>
|
||||
) : discoveredModels.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_empty')}</div>
|
||||
) : discoveredModelsFiltered.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_search_empty')}</div>
|
||||
) : (
|
||||
<div className={styles.modelDiscoveryList}>
|
||||
{discoveredModelsFiltered.map((model) => {
|
||||
const checked = modelDiscoverySelected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
key={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleModelDiscoverySelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { modelsApi, providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { GeminiKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
import type { GeminiFormState } from '@/components/providers';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
import styles from './AiProvidersPage.module.scss';
|
||||
|
||||
type LocationState = { fromAiProviders?: boolean } | null;
|
||||
|
||||
const buildEmptyForm = (): GeminiFormState => ({
|
||||
apiKey: '',
|
||||
priority: undefined,
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
headers: [],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
excludedModels: [],
|
||||
excludedText: '',
|
||||
});
|
||||
@@ -33,6 +41,48 @@ const parseIndexParam = (value: string | undefined) => {
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const stripGeminiModelResourceName = (value: string) => {
|
||||
return String(value ?? '').trim().replace(/^\/?models\//i, '');
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = stripGeminiModelResourceName(entry?.name ?? '').trim();
|
||||
let alias = String(entry?.alias ?? '').trim();
|
||||
if (name && alias === name) {
|
||||
alias = '';
|
||||
}
|
||||
if (!name && !alias) return acc;
|
||||
acc.push({ name, alias });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const buildGeminiSignature = (form: GeminiFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
headers: normalizeHeaderEntries(form.headers),
|
||||
models: normalizeModelEntries(form.modelEntries),
|
||||
excludedModels: parseExcludedModels(form.excludedText ?? ''),
|
||||
});
|
||||
|
||||
export function AiProvidersGeminiEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -52,6 +102,16 @@ export function AiProvidersGeminiEditPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<GeminiFormState>(() => buildEmptyForm());
|
||||
const [baselineSignature, setBaselineSignature] = useState(() => buildGeminiSignature(buildEmptyForm()));
|
||||
|
||||
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
|
||||
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
|
||||
const [discoveredModels, setDiscoveredModels] = useState<ModelInfo[]>([]);
|
||||
const [modelDiscoveryFetching, setModelDiscoveryFetching] = useState(false);
|
||||
const [modelDiscoveryError, setModelDiscoveryError] = useState('');
|
||||
const [modelDiscoverySearch, setModelDiscoverySearch] = useState('');
|
||||
const [modelDiscoverySelected, setModelDiscoverySelected] = useState<Set<string>>(new Set());
|
||||
const autoFetchSignatureRef = useRef<string>('');
|
||||
|
||||
const hasIndexParam = typeof params.index === 'string';
|
||||
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||
@@ -117,30 +177,196 @@ export function AiProvidersGeminiEditPage() {
|
||||
if (loading) return;
|
||||
|
||||
if (initialData) {
|
||||
setForm({
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
const { headers, models, ...rest } = initialData;
|
||||
const nextForm: GeminiFormState = {
|
||||
...rest,
|
||||
headers: headersToEntries(headers),
|
||||
modelEntries: modelsToEntries(models).map((entry) => ({
|
||||
...entry,
|
||||
name: stripGeminiModelResourceName(entry.name),
|
||||
})),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
};
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildGeminiSignature(nextForm));
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
const nextForm = buildEmptyForm();
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildGeminiSignature(nextForm));
|
||||
}, [initialData, loading]);
|
||||
|
||||
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
const discoveredModelsFiltered = useMemo(() => {
|
||||
const filter = modelDiscoverySearch.trim().toLowerCase();
|
||||
if (!filter) return discoveredModels;
|
||||
return discoveredModels.filter((model) => {
|
||||
const name = (model.name || '').toLowerCase();
|
||||
const alias = (model.alias || '').toLowerCase();
|
||||
const description = (model.description || '').toLowerCase();
|
||||
return name.includes(filter) || alias.includes(filter) || description.includes(filter);
|
||||
});
|
||||
}, [discoveredModels, modelDiscoverySearch]);
|
||||
|
||||
const mergeDiscoveredModels = useCallback(
|
||||
(selectedModels: ModelInfo[]) => {
|
||||
if (!selectedModels.length) return;
|
||||
|
||||
let addedCount = 0;
|
||||
setForm((prev) => {
|
||||
const mergedMap = new Map<string, { name: string; alias: string }>();
|
||||
prev.modelEntries.forEach((entry) => {
|
||||
const name = stripGeminiModelResourceName(entry.name);
|
||||
if (!name) return;
|
||||
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
|
||||
});
|
||||
|
||||
selectedModels.forEach((model) => {
|
||||
const name = stripGeminiModelResourceName(model.name);
|
||||
if (!name || mergedMap.has(name)) return;
|
||||
mergedMap.set(name, { name, alias: model.alias ?? '' });
|
||||
addedCount += 1;
|
||||
});
|
||||
|
||||
const mergedEntries = Array.from(mergedMap.values());
|
||||
return {
|
||||
...prev,
|
||||
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
|
||||
};
|
||||
});
|
||||
|
||||
if (addedCount > 0) {
|
||||
showNotification(t('ai_providers.gemini_models_fetch_added', { count: addedCount }), 'success');
|
||||
}
|
||||
},
|
||||
[setForm, showNotification, t]
|
||||
);
|
||||
|
||||
const fetchGeminiModelDiscovery = useCallback(async () => {
|
||||
setModelDiscoveryFetching(true);
|
||||
setModelDiscoveryError('');
|
||||
const headerObject = buildHeaderObject(form.headers);
|
||||
try {
|
||||
const list = await modelsApi.fetchGeminiModelsViaApiCall(
|
||||
form.baseUrl ?? '',
|
||||
form.apiKey.trim() || undefined,
|
||||
headerObject
|
||||
);
|
||||
setDiscoveredModels(list);
|
||||
} catch (err: unknown) {
|
||||
setDiscoveredModels([]);
|
||||
const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
||||
const hasCustomXGoogApiKey = Object.keys(headerObject).some(
|
||||
(key) => key.toLowerCase() === 'x-goog-api-key'
|
||||
);
|
||||
const hasAuthorization = Object.keys(headerObject).some(
|
||||
(key) => key.toLowerCase() === 'authorization'
|
||||
);
|
||||
const shouldAttachDiag = message.toLowerCase().includes('api key') || message.includes('401');
|
||||
const diag = shouldAttachDiag
|
||||
? ` [diag: apiKeyField=${form.apiKey.trim() ? 'yes' : 'no'}, customXGoogApiKey=${
|
||||
hasCustomXGoogApiKey ? 'yes' : 'no'
|
||||
}, customAuthorization=${hasAuthorization ? 'yes' : 'no'}]`
|
||||
: '';
|
||||
setModelDiscoveryError(`${t('ai_providers.gemini_models_fetch_error')}: ${message}${diag}`);
|
||||
} finally {
|
||||
setModelDiscoveryFetching(false);
|
||||
}
|
||||
}, [form.apiKey, form.baseUrl, form.headers, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelDiscoveryOpen) {
|
||||
autoFetchSignatureRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEndpoint = modelsApi.buildGeminiModelsEndpoint(form.baseUrl ?? '');
|
||||
setModelDiscoveryEndpoint(nextEndpoint);
|
||||
setDiscoveredModels([]);
|
||||
setModelDiscoverySearch('');
|
||||
setModelDiscoverySelected(new Set());
|
||||
setModelDiscoveryError('');
|
||||
|
||||
const headerObject = buildHeaderObject(form.headers);
|
||||
const hasCustomXGoogApiKey = Object.keys(headerObject).some(
|
||||
(key) => key.toLowerCase() === 'x-goog-api-key'
|
||||
);
|
||||
const hasAuthorization = Object.keys(headerObject).some(
|
||||
(key) => key.toLowerCase() === 'authorization'
|
||||
);
|
||||
const hasApiKeyField = Boolean(form.apiKey.trim());
|
||||
const canAutoFetch = hasApiKeyField || hasCustomXGoogApiKey || hasAuthorization;
|
||||
|
||||
if (!canAutoFetch) return;
|
||||
|
||||
const headerSignature = Object.entries(headerObject)
|
||||
.sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase()))
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join('|');
|
||||
const signature = `${nextEndpoint}||${form.apiKey.trim()}||${headerSignature}`;
|
||||
if (autoFetchSignatureRef.current === signature) return;
|
||||
autoFetchSignatureRef.current = signature;
|
||||
|
||||
void fetchGeminiModelDiscovery();
|
||||
}, [fetchGeminiModelDiscovery, form.apiKey, form.baseUrl, form.headers, modelDiscoveryOpen]);
|
||||
|
||||
const toggleModelDiscoverySelection = (name: string) => {
|
||||
setModelDiscoverySelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleApplyDiscoveredModels = () => {
|
||||
const selectedModels = discoveredModels.filter((model) => modelDiscoverySelected.has(model.name));
|
||||
if (selectedModels.length) {
|
||||
mergeDiscoveredModels(selectedModels);
|
||||
}
|
||||
setModelDiscoveryOpen(false);
|
||||
};
|
||||
|
||||
const currentSignature = useMemo(() => buildGeminiSignature(form), [form]);
|
||||
const isDirty = baselineSignature !== currentSignature;
|
||||
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
const { allowNextNavigation } = useUnsavedChangesGuard({
|
||||
enabled: canGuard,
|
||||
shouldBlock: ({ currentLocation, nextLocation }) =>
|
||||
isDirty && currentLocation.pathname !== nextLocation.pathname,
|
||||
dialog: {
|
||||
title: t('common.unsaved_changes_title'),
|
||||
message: t('common.unsaved_changes_message'),
|
||||
confirmText: t('common.leave'),
|
||||
cancelText: t('common.stay'),
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const normalizedModelEntries = form.modelEntries.map((entry) => ({
|
||||
...entry,
|
||||
name: stripGeminiModelResourceName(entry.name),
|
||||
}));
|
||||
|
||||
const payload: GeminiKeyConfig = {
|
||||
apiKey: form.apiKey.trim(),
|
||||
priority: form.priority !== undefined ? Math.trunc(form.priority) : undefined,
|
||||
prefix: form.prefix?.trim() || undefined,
|
||||
baseUrl: form.baseUrl?.trim() || undefined,
|
||||
proxyUrl: form.proxyUrl?.trim() || undefined,
|
||||
headers: buildHeaderObject(form.headers),
|
||||
models: entriesToModels(normalizedModelEntries),
|
||||
excludedModels: parseExcludedModels(form.excludedText),
|
||||
};
|
||||
|
||||
@@ -156,6 +382,8 @@ export function AiProvidersGeminiEditPage() {
|
||||
editIndex !== null ? t('notification.gemini_key_updated') : t('notification.gemini_key_added'),
|
||||
'success'
|
||||
);
|
||||
allowNextNavigation();
|
||||
setBaselineSignature(buildGeminiSignature(form));
|
||||
handleBack();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
@@ -165,6 +393,7 @@ export function AiProvidersGeminiEditPage() {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
allowNextNavigation,
|
||||
canSave,
|
||||
clearCache,
|
||||
configs,
|
||||
@@ -176,6 +405,9 @@ export function AiProvidersGeminiEditPage() {
|
||||
updateConfigValue,
|
||||
]);
|
||||
|
||||
const canOpenModelDiscovery = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
const canApplyModelDiscovery = !disableControls && !saving && !modelDiscoveryFetching;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
ref={swipeRef}
|
||||
@@ -205,6 +437,22 @@ export function AiProvidersGeminiEditPage() {
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.priority_label')}
|
||||
hint={t('ai_providers.priority_hint')}
|
||||
type="number"
|
||||
step={1}
|
||||
value={form.priority ?? ''}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const parsed = raw.trim() === '' ? undefined : Number(raw);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
priority: parsed !== undefined && Number.isFinite(parsed) ? parsed : undefined,
|
||||
}));
|
||||
}}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
@@ -237,6 +485,52 @@ export function AiProvidersGeminiEditPage() {
|
||||
removeButtonAriaLabel={t('common.delete')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
|
||||
<div className={styles.modelConfigSection}>
|
||||
<div className={styles.modelConfigHeader}>
|
||||
<label className={styles.modelConfigTitle}>{t('ai_providers.gemini_models_label')}</label>
|
||||
<div className={styles.modelConfigToolbar}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelEntries: [...prev.modelEntries, { name: '', alias: '' }],
|
||||
}))
|
||||
}
|
||||
disabled={disableControls || saving}
|
||||
>
|
||||
{t('ai_providers.gemini_models_add_btn')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setModelDiscoveryOpen(true)}
|
||||
disabled={!canOpenModelDiscovery}
|
||||
>
|
||||
{t('ai_providers.gemini_models_fetch_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_hint')}</div>
|
||||
|
||||
<ModelInputList
|
||||
entries={form.modelEntries}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||
namePlaceholder={t('common.model_name_placeholder')}
|
||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||
disabled={disableControls || saving}
|
||||
hideAddButton
|
||||
className={styles.modelInputList}
|
||||
rowClassName={styles.modelInputRow}
|
||||
inputClassName={styles.modelInputField}
|
||||
removeButtonClassName={styles.modelRowRemoveButton}
|
||||
removeButtonTitle={t('common.delete')}
|
||||
removeButtonAriaLabel={t('common.delete')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
@@ -249,6 +543,99 @@ export function AiProvidersGeminiEditPage() {
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={modelDiscoveryOpen}
|
||||
title={t('ai_providers.gemini_models_fetch_title')}
|
||||
onClose={() => setModelDiscoveryOpen(false)}
|
||||
width={720}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setModelDiscoveryOpen(false)}
|
||||
disabled={modelDiscoveryFetching}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleApplyDiscoveredModels} disabled={!canApplyModelDiscovery}>
|
||||
{t('ai_providers.gemini_models_fetch_apply')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.openaiModelsContent}>
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_hint')}</div>
|
||||
<div className={styles.openaiModelsEndpointSection}>
|
||||
<label className={styles.openaiModelsEndpointLabel}>
|
||||
{t('ai_providers.gemini_models_fetch_url_label')}
|
||||
</label>
|
||||
<div className={styles.openaiModelsEndpointControls}>
|
||||
<input
|
||||
className={`input ${styles.openaiModelsEndpointInput}`}
|
||||
readOnly
|
||||
value={modelDiscoveryEndpoint}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void fetchGeminiModelDiscovery()}
|
||||
loading={modelDiscoveryFetching}
|
||||
disabled={disableControls || saving}
|
||||
>
|
||||
{t('ai_providers.gemini_models_fetch_refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t('ai_providers.gemini_models_search_label')}
|
||||
placeholder={t('ai_providers.gemini_models_search_placeholder')}
|
||||
value={modelDiscoverySearch}
|
||||
onChange={(e) => setModelDiscoverySearch(e.target.value)}
|
||||
disabled={modelDiscoveryFetching}
|
||||
/>
|
||||
{modelDiscoveryError && <div className="error-box">{modelDiscoveryError}</div>}
|
||||
{modelDiscoveryFetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_loading')}</div>
|
||||
) : discoveredModels.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_empty')}</div>
|
||||
) : discoveredModelsFiltered.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_search_empty')}</div>
|
||||
) : (
|
||||
<div className={styles.modelDiscoveryList}>
|
||||
{discoveredModelsFiltered.map((model) => {
|
||||
const checked = modelDiscoverySelected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
key={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleModelDiscoverySelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -2,12 +2,13 @@ import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useOpenAIEditDraftStore } from '@/stores';
|
||||
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import { buildApiKeyEntry } from '@/components/providers/utils';
|
||||
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
|
||||
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
|
||||
@@ -41,6 +42,7 @@ export type OpenAIEditOutletContext = {
|
||||
|
||||
const buildEmptyForm = (): OpenAIFormState => ({
|
||||
name: '',
|
||||
priority: undefined,
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
headers: [],
|
||||
@@ -61,6 +63,72 @@ const getErrorMessage = (err: unknown) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeModelEntries = (entries: ModelEntry[]) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = String(entry?.name ?? '').trim();
|
||||
let alias = String(entry?.alias ?? '').trim();
|
||||
if (name && (alias === '' || alias === name)) {
|
||||
alias = '';
|
||||
}
|
||||
if (!name && !alias) return acc;
|
||||
acc.push({ name, alias });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const normalizeKeyHeaders = (headers: ApiKeyEntry['headers']) => {
|
||||
if (!headers || typeof headers !== 'object') return [];
|
||||
return Object.entries(headers)
|
||||
.map(([key, value]) => ({ key: String(key ?? '').trim(), value: String(value ?? '').trim() }))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeApiKeyEntries = (entries: ApiKeyEntry[]) =>
|
||||
(entries ?? []).reduce<
|
||||
Array<{
|
||||
apiKey: string;
|
||||
proxyUrl: string;
|
||||
headers: Array<{ key: string; value: string }>;
|
||||
}>
|
||||
>((acc, entry) => {
|
||||
const apiKey = String(entry?.apiKey ?? '').trim();
|
||||
const proxyUrl = String(entry?.proxyUrl ?? '').trim();
|
||||
const headers = normalizeKeyHeaders(entry?.headers);
|
||||
if (!apiKey && !proxyUrl && headers.length === 0) return acc;
|
||||
acc.push({ apiKey, proxyUrl, headers });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const buildOpenAISignature = (form: OpenAIFormState, testModel: string) =>
|
||||
JSON.stringify({
|
||||
name: String(form.name ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
headers: normalizeHeaderEntries(form.headers),
|
||||
apiKeyEntries: normalizeApiKeyEntries(form.apiKeyEntries),
|
||||
models: normalizeModelEntries(form.modelEntries),
|
||||
testModel: String(testModel ?? '').trim(),
|
||||
});
|
||||
|
||||
export function AiProvidersOpenAIEditLayout() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -94,9 +162,10 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
}, [editIndex, invalidIndexParam, params.index]);
|
||||
|
||||
const draft = useOpenAIEditDraftStore((state) => state.drafts[draftKey]);
|
||||
const ensureDraft = useOpenAIEditDraftStore((state) => state.ensureDraft);
|
||||
const acquireDraft = useOpenAIEditDraftStore((state) => state.acquireDraft);
|
||||
const releaseDraft = useOpenAIEditDraftStore((state) => state.releaseDraft);
|
||||
const initDraft = useOpenAIEditDraftStore((state) => state.initDraft);
|
||||
const clearDraft = useOpenAIEditDraftStore((state) => state.clearDraft);
|
||||
const setDraftBaselineSignature = useOpenAIEditDraftStore((state) => state.setDraftBaselineSignature);
|
||||
const setDraftForm = useOpenAIEditDraftStore((state) => state.setDraftForm);
|
||||
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
|
||||
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
|
||||
@@ -166,18 +235,18 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
ensureDraft(draftKey);
|
||||
}, [draftKey, ensureDraft]);
|
||||
acquireDraft(draftKey);
|
||||
return () => releaseDraft(draftKey);
|
||||
}, [acquireDraft, draftKey, releaseDraft]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
clearDraft(draftKey);
|
||||
const state = location.state as LocationState;
|
||||
if (state?.fromAiProviders) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
navigate('/ai-providers', { replace: true });
|
||||
}, [clearDraft, draftKey, location.state, navigate]);
|
||||
}, [location.state, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -214,6 +283,7 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
const modelEntries = modelsToEntries(initialData.models);
|
||||
const seededForm: OpenAIFormState = {
|
||||
name: initialData.name,
|
||||
priority: initialData.priority,
|
||||
prefix: initialData.prefix ?? '',
|
||||
baseUrl: initialData.baseUrl,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
@@ -229,7 +299,9 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
initialData.testModel && available.includes(initialData.testModel)
|
||||
? initialData.testModel
|
||||
: available[0] || '';
|
||||
const baselineSignature = buildOpenAISignature(seededForm, initialTestModel);
|
||||
initDraft(draftKey, {
|
||||
baselineSignature,
|
||||
form: seededForm,
|
||||
testModel: initialTestModel,
|
||||
testStatus: 'idle',
|
||||
@@ -237,8 +309,10 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
keyTestStatuses: [],
|
||||
});
|
||||
} else {
|
||||
const emptyForm = buildEmptyForm();
|
||||
initDraft(draftKey, {
|
||||
form: buildEmptyForm(),
|
||||
baselineSignature: buildOpenAISignature(emptyForm, ''),
|
||||
form: emptyForm,
|
||||
testModel: '',
|
||||
testStatus: 'idle',
|
||||
testMessage: '',
|
||||
@@ -300,6 +374,35 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
[setForm, showNotification, t]
|
||||
);
|
||||
|
||||
const resolvedLoading = !draft?.initialized;
|
||||
const currentSignature = useMemo(() => buildOpenAISignature(form, testModel), [form, testModel]);
|
||||
const baselineSignature = draft?.baselineSignature ?? '';
|
||||
const isDirty = Boolean(draft?.initialized) && baselineSignature !== currentSignature;
|
||||
const editorRootPath = useMemo(() => {
|
||||
if (hasIndexParam) {
|
||||
return `/ai-providers/openai/${params.index ?? ''}`;
|
||||
}
|
||||
return '/ai-providers/openai/new';
|
||||
}, [hasIndexParam, params.index]);
|
||||
const canGuard = !resolvedLoading && !saving && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
const { allowNextNavigation } = useUnsavedChangesGuard({
|
||||
enabled: canGuard,
|
||||
shouldBlock: ({ nextLocation }) => {
|
||||
const nextPath = nextLocation.pathname;
|
||||
const isWithinRoot =
|
||||
nextPath === editorRootPath || nextPath.startsWith(`${editorRootPath}/`);
|
||||
return isDirty && !isWithinRoot;
|
||||
},
|
||||
dialog: {
|
||||
title: t('common.unsaved_changes_title'),
|
||||
message: t('common.unsaved_changes_message'),
|
||||
confirmText: t('common.leave'),
|
||||
cancelText: t('common.stay'),
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const name = form.name.trim();
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
@@ -322,6 +425,9 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
headers: entry.headers,
|
||||
})),
|
||||
};
|
||||
if (form.priority !== undefined && Number.isFinite(form.priority)) {
|
||||
payload.priority = Math.trunc(form.priority);
|
||||
}
|
||||
const resolvedTestModel = testModel.trim();
|
||||
if (resolvedTestModel) payload.testModel = resolvedTestModel;
|
||||
const models = entriesToModels(form.modelEntries);
|
||||
@@ -351,6 +457,8 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
: t('notification.openai_provider_added'),
|
||||
'success'
|
||||
);
|
||||
allowNextNavigation();
|
||||
setDraftBaselineSignature(draftKey, buildOpenAISignature(form, testModel));
|
||||
handleBack();
|
||||
} catch (err: unknown) {
|
||||
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
|
||||
@@ -358,18 +466,19 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
allowNextNavigation,
|
||||
draftKey,
|
||||
editIndex,
|
||||
fetchConfig,
|
||||
form,
|
||||
handleBack,
|
||||
providers,
|
||||
testModel,
|
||||
setDraftBaselineSignature,
|
||||
showNotification,
|
||||
t,
|
||||
testModel,
|
||||
]);
|
||||
|
||||
const resolvedLoading = !draft?.initialized;
|
||||
|
||||
return (
|
||||
<Outlet
|
||||
context={{
|
||||
@@ -399,4 +508,3 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -520,6 +520,22 @@ export function AiProvidersOpenAIEditPage() {
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
disabled={saving || disableControls || isTestingKeys}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.priority_label')}
|
||||
hint={t('ai_providers.priority_hint')}
|
||||
type="number"
|
||||
step={1}
|
||||
value={form.priority ?? ''}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const parsed = raw.trim() === '' ? undefined : Number(raw);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
priority: parsed !== undefined && Number.isFinite(parsed) ? parsed : undefined,
|
||||
}));
|
||||
}}
|
||||
disabled={saving || disableControls || isTestingKeys}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
|
||||
@@ -570,6 +570,10 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.modelConfigSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.status-badge) {
|
||||
margin-bottom: 0;
|
||||
align-self: flex-start;
|
||||
@@ -623,17 +627,17 @@
|
||||
}
|
||||
|
||||
.modelConfigSection {
|
||||
margin-bottom: 0;
|
||||
margin-bottom: $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.modelConfigHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@include mobile {
|
||||
@@ -645,7 +649,6 @@
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
withDisableAllModelsRule,
|
||||
withoutDisableAllModelsRule,
|
||||
} from '@/components/providers/utils';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { ampcodeApi, providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
|
||||
@@ -58,7 +59,7 @@ export function AiProvidersPage() {
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
const isSwitching = Boolean(configSwitchingKey);
|
||||
|
||||
const { keyStats, usageDetails, loadKeyStats } = useProviderStats();
|
||||
const { keyStats, usageDetails, loadKeyStats, refreshKeyStats } = useProviderStats();
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
@@ -129,6 +130,8 @@ export function AiProvidersPage() {
|
||||
config?.openaiCompatibility,
|
||||
]);
|
||||
|
||||
useHeaderRefresh(refreshKeyStats);
|
||||
|
||||
const openEditor = useCallback(
|
||||
(path: string) => {
|
||||
navigate(path, { state: { fromAiProviders: true } });
|
||||
|
||||
@@ -8,11 +8,12 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import type { VertexFormState } from '@/components/providers';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
|
||||
@@ -34,6 +35,38 @@ const parseIndexParam = (value: string | undefined) => {
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = String(entry?.name ?? '').trim();
|
||||
const alias = String(entry?.alias ?? '').trim();
|
||||
if (!name && !alias) return acc;
|
||||
acc.push({ name, alias });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const buildVertexSignature = (form: VertexFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
headers: normalizeHeaderEntries(form.headers),
|
||||
models: normalizeModelEntries(form.modelEntries),
|
||||
});
|
||||
|
||||
export function AiProvidersVertexEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -53,6 +86,7 @@ export function AiProvidersVertexEditPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<VertexFormState>(() => buildEmptyForm());
|
||||
const [baselineSignature, setBaselineSignature] = useState(() => buildVertexSignature(buildEmptyForm()));
|
||||
|
||||
const hasIndexParam = typeof params.index === 'string';
|
||||
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||
@@ -126,18 +160,39 @@ export function AiProvidersVertexEditPage() {
|
||||
if (loading) return;
|
||||
|
||||
if (initialData) {
|
||||
setForm({
|
||||
const nextForm: VertexFormState = {
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
modelEntries: modelsToEntries(initialData.models),
|
||||
});
|
||||
};
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildVertexSignature(nextForm));
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
const nextForm = buildEmptyForm();
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildVertexSignature(nextForm));
|
||||
}, [initialData, loading]);
|
||||
|
||||
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
const currentSignature = useMemo(() => buildVertexSignature(form), [form]);
|
||||
const isDirty = baselineSignature !== currentSignature;
|
||||
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
const { allowNextNavigation } = useUnsavedChangesGuard({
|
||||
enabled: canGuard,
|
||||
shouldBlock: ({ currentLocation, nextLocation }) =>
|
||||
isDirty && currentLocation.pathname !== nextLocation.pathname,
|
||||
dialog: {
|
||||
title: t('common.unsaved_changes_title'),
|
||||
message: t('common.unsaved_changes_message'),
|
||||
confirmText: t('common.leave'),
|
||||
cancelText: t('common.stay'),
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
|
||||
@@ -179,6 +234,8 @@ export function AiProvidersVertexEditPage() {
|
||||
editIndex !== null ? t('notification.vertex_config_updated') : t('notification.vertex_config_added'),
|
||||
'success'
|
||||
);
|
||||
allowNextNavigation();
|
||||
setBaselineSignature(buildVertexSignature(form));
|
||||
handleBack();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
@@ -188,6 +245,7 @@ export function AiProvidersVertexEditPage() {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
allowNextNavigation,
|
||||
canSave,
|
||||
clearCache,
|
||||
configs,
|
||||
|
||||
@@ -605,6 +605,69 @@
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cardHealthRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.healthStatusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.healthStatusHealthy {
|
||||
color: var(--success-badge-text, #065f46);
|
||||
background-color: var(--success-badge-bg, #d1fae5);
|
||||
border-color: var(--success-badge-border, #6ee7b7);
|
||||
}
|
||||
|
||||
.healthStatusWarning {
|
||||
color: var(--warning-text);
|
||||
background-color: var(--warning-bg);
|
||||
border-color: var(--warning-border);
|
||||
}
|
||||
|
||||
.healthStatusDisabled {
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-tertiary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.healthStatusUnknown {
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-secondary);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.lastRefreshText {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.lastRefreshStale {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.healthStatusMessage {
|
||||
font-size: 12px;
|
||||
color: var(--warning-text);
|
||||
background-color: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: $radius-sm;
|
||||
padding: 5px 8px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.cardStats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -58,11 +58,12 @@ export function AuthFilesPage() {
|
||||
const [selectedFile, setSelectedFile] = useState<AuthFileItem | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
|
||||
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
|
||||
const previousSelectionCountRef = useRef(0);
|
||||
const selectionCountRef = useRef(0);
|
||||
|
||||
const { keyStats, usageDetails, loadKeyStats } = useAuthFilesStats();
|
||||
const { keyStats, usageDetails, loadKeyStats, refreshKeyStats } = useAuthFilesStats();
|
||||
const {
|
||||
files,
|
||||
selectedFiles,
|
||||
@@ -86,7 +87,7 @@ export function AuthFilesPage() {
|
||||
deselectAll,
|
||||
batchSetStatus,
|
||||
batchDelete
|
||||
} = useAuthFilesData({ refreshKeyStats: loadKeyStats });
|
||||
} = useAuthFilesData({ refreshKeyStats });
|
||||
|
||||
const statusBarCache = useAuthFilesStatusBarCache(files, usageDetails);
|
||||
|
||||
@@ -129,7 +130,7 @@ export function AuthFilesPage() {
|
||||
} = useAuthFilesPrefixProxyEditor({
|
||||
disableControls: connectionStatus !== 'connected',
|
||||
loadFiles,
|
||||
loadKeyStats
|
||||
loadKeyStats: refreshKeyStats
|
||||
});
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
@@ -203,8 +204,8 @@ export function AuthFilesPage() {
|
||||
};
|
||||
|
||||
const handleHeaderRefresh = useCallback(async () => {
|
||||
await Promise.all([loadFiles(), loadKeyStats(), loadExcluded(), loadModelAlias()]);
|
||||
}, [loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
|
||||
await Promise.all([loadFiles(), refreshKeyStats(), loadExcluded(), loadModelAlias()]);
|
||||
}, [loadFiles, refreshKeyStats, loadExcluded, loadModelAlias]);
|
||||
|
||||
useHeaderRefresh(handleHeaderRefresh);
|
||||
|
||||
@@ -216,7 +217,13 @@ export function AuthFilesPage() {
|
||||
loadModelAlias();
|
||||
}, [isCurrentLayer, loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
|
||||
|
||||
useInterval(loadKeyStats, isCurrentLayer ? 240_000 : null);
|
||||
useInterval(
|
||||
() => {
|
||||
void refreshKeyStats();
|
||||
},
|
||||
isCurrentLayer ? 240_000 : null
|
||||
);
|
||||
useInterval(() => setNowMs(Date.now()), isCurrentLayer ? 60_000 : null);
|
||||
|
||||
const existingTypes = useMemo(() => {
|
||||
const types = new Set<string>(['all']);
|
||||
@@ -512,6 +519,7 @@ export function AuthFilesPage() {
|
||||
quotaFilterType={quotaFilterType}
|
||||
keyStats={keyStats}
|
||||
statusBarCache={statusBarCache}
|
||||
nowMs={nowMs}
|
||||
onShowModels={showModels}
|
||||
onShowDetails={showDetails}
|
||||
onDownload={handleDownload}
|
||||
|
||||
@@ -261,6 +261,39 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.configBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.configBadgeRoundRobin {
|
||||
color: var(--primary-color);
|
||||
border-color: rgba($primary-color, 0.24);
|
||||
background: rgba($primary-color, 0.12);
|
||||
}
|
||||
|
||||
.configBadgeFillFirst {
|
||||
color: $success-color;
|
||||
border-color: rgba($success-color, 0.3);
|
||||
background: rgba($success-color, 0.12);
|
||||
}
|
||||
|
||||
.configBadgeUnknown {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.configItemFull {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -221,6 +221,22 @@ export function DashboardPage() {
|
||||
}
|
||||
];
|
||||
|
||||
const routingStrategyRaw = config?.routingStrategy?.trim() || '';
|
||||
const routingStrategyDisplay = !routingStrategyRaw
|
||||
? '-'
|
||||
: routingStrategyRaw === 'round-robin'
|
||||
? t('basic_settings.routing_strategy_round_robin')
|
||||
: routingStrategyRaw === 'fill-first'
|
||||
? t('basic_settings.routing_strategy_fill_first')
|
||||
: routingStrategyRaw;
|
||||
const routingStrategyBadgeClass = !routingStrategyRaw
|
||||
? styles.configBadgeUnknown
|
||||
: routingStrategyRaw === 'round-robin'
|
||||
? styles.configBadgeRoundRobin
|
||||
: routingStrategyRaw === 'fill-first'
|
||||
? styles.configBadgeFillFirst
|
||||
: styles.configBadgeUnknown;
|
||||
|
||||
return (
|
||||
<div className={styles.dashboard}>
|
||||
<div className={styles.header}>
|
||||
@@ -311,6 +327,12 @@ export function DashboardPage() {
|
||||
{config.wsAuth ? t('common.yes') : t('common.no')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.configItem}>
|
||||
<span className={styles.configLabel}>{t('dashboard.routing_strategy')}</span>
|
||||
<span className={`${styles.configBadge} ${routingStrategyBadgeClass}`}>
|
||||
{routingStrategyDisplay}
|
||||
</span>
|
||||
</div>
|
||||
{config.proxyUrl && (
|
||||
<div className={`${styles.configItem} ${styles.configItemFull}`}>
|
||||
<span className={styles.configLabel}>{t('basic_settings.proxy_url_label')}</span>
|
||||
|
||||
@@ -121,6 +121,96 @@
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.structuredFilters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1 1 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.filterChipGroup {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterChipLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
line-height: 28px;
|
||||
min-width: 68px;
|
||||
}
|
||||
|
||||
.filterChipList {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filterChip {
|
||||
@include button-reset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
cursor: pointer;
|
||||
max-width: 280px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.filterChipActive {
|
||||
color: var(--primary-color);
|
||||
border-color: rgba($primary-color, 0.45);
|
||||
background: rgba($primary-color, 0.14);
|
||||
}
|
||||
|
||||
.filterChipHint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.filterChipGroup {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filterChipLabel {
|
||||
min-width: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
padding-right: 44px !important;
|
||||
}
|
||||
@@ -477,6 +567,168 @@
|
||||
}
|
||||
}
|
||||
|
||||
.traceButton {
|
||||
@include button-reset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid rgba($primary-color, 0.3);
|
||||
background: rgba($primary-color, 0.1);
|
||||
color: var(--primary-color);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba($primary-color, 0.16);
|
||||
border-color: rgba($primary-color, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.tracePanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.traceNotice {
|
||||
padding: 8px 10px;
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid var(--warning-border);
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.traceSectionTitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.traceInfoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.traceInfoItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.traceInfoItemWide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.traceInfoLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.traceInfoValue {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.traceSourceType {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 6px;
|
||||
padding: 0 6px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.traceCandidates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.traceCandidate {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
padding: 10px;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.traceCandidateHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.traceConfidenceBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.traceConfidenceHigh {
|
||||
color: var(--success-badge-text, #065f46);
|
||||
background: var(--success-badge-bg, #d1fae5);
|
||||
border-color: var(--success-badge-border, #6ee7b7);
|
||||
}
|
||||
|
||||
.traceConfidenceMedium {
|
||||
color: var(--warning-text);
|
||||
background: var(--warning-bg);
|
||||
border-color: var(--warning-border);
|
||||
}
|
||||
|
||||
.traceConfidenceLow {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.traceScore,
|
||||
.traceDelta {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.traceCandidateGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 820px) {
|
||||
.pageTitle {
|
||||
font-size: 24px;
|
||||
|
||||
+679
-27
@@ -19,10 +19,18 @@ import {
|
||||
} from '@/components/ui/icons';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { authFilesApi } from '@/services/api/authFiles';
|
||||
import { logsApi } from '@/services/api/logs';
|
||||
import { usageApi } from '@/services/api/usage';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
|
||||
import { formatUnixTimestamp } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
collectUsageDetailsWithEndpoint,
|
||||
type UsageDetailWithEndpoint
|
||||
} from '@/utils/usage';
|
||||
import styles from './LogsPage.module.scss';
|
||||
|
||||
interface ErrorLogItem {
|
||||
@@ -49,6 +57,9 @@ const LONG_PRESS_MOVE_THRESHOLD = 10;
|
||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
|
||||
type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||
const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
|
||||
const STATUS_GROUPS = ['2xx', '3xx', '4xx', '5xx'] as const;
|
||||
type StatusGroup = (typeof STATUS_GROUPS)[number];
|
||||
const PATH_FILTER_LIMIT = 12;
|
||||
|
||||
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
|
||||
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\s*\]?(?=\s|\[|$)\s*/i;
|
||||
@@ -81,6 +92,15 @@ const detectHttpStatusCode = (text: string): number | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const resolveStatusGroup = (statusCode?: number): StatusGroup | undefined => {
|
||||
if (typeof statusCode !== 'number') return undefined;
|
||||
if (statusCode >= 200 && statusCode < 300) return '2xx';
|
||||
if (statusCode >= 300 && statusCode < 400) return '3xx';
|
||||
if (statusCode >= 400 && statusCode < 500) return '4xx';
|
||||
if (statusCode >= 500 && statusCode < 600) return '5xx';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractIp = (text: string): string | undefined => {
|
||||
const ipv4Match = text.match(LOG_IPV4_REGEX);
|
||||
if (ipv4Match) return ipv4Match[0];
|
||||
@@ -126,6 +146,124 @@ type ParsedLogLine = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
type TraceConfidence = 'high' | 'medium' | 'low';
|
||||
|
||||
type TraceCandidate = {
|
||||
detail: UsageDetailWithEndpoint;
|
||||
score: number;
|
||||
confidence: TraceConfidence;
|
||||
timeDeltaMs: number | null;
|
||||
};
|
||||
|
||||
type TraceCredentialInfo = {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type TraceSourceInfo = {
|
||||
displayName: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
const TRACE_USAGE_CACHE_MS = 60 * 1000;
|
||||
const TRACE_MATCH_STRONG_WINDOW_MS = 3 * 1000;
|
||||
const TRACE_MATCH_WINDOW_MS = 10 * 1000;
|
||||
const TRACE_MATCH_MAX_WINDOW_MS = 30 * 1000;
|
||||
|
||||
const normalizeTraceAuthIndex = (value: unknown): string | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeTracePath = (value?: string) =>
|
||||
String(value ?? '')
|
||||
.replace(/^"+|"+$/g, '')
|
||||
.split('?')[0]
|
||||
.trim();
|
||||
|
||||
const TRACEABLE_EXACT_PATHS = new Set(['/v1/chat/completions', '/v1/messages', '/v1/responses']);
|
||||
const TRACEABLE_PREFIX_PATHS = ['/v1beta/models'];
|
||||
|
||||
const normalizeTraceablePath = (value?: string): string => {
|
||||
const normalized = normalizeTracePath(value);
|
||||
if (!normalized || normalized === '/') return normalized;
|
||||
return normalized.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
const isTraceableRequestPath = (value?: string): boolean => {
|
||||
const normalizedPath = normalizeTraceablePath(value);
|
||||
if (!normalizedPath) return false;
|
||||
if (TRACEABLE_EXACT_PATHS.has(normalizedPath)) return true;
|
||||
return TRACEABLE_PREFIX_PATHS.some((prefix) => normalizedPath.startsWith(prefix));
|
||||
};
|
||||
|
||||
const scoreTraceCandidate = (
|
||||
line: ParsedLogLine,
|
||||
detail: UsageDetailWithEndpoint
|
||||
): TraceCandidate | null => {
|
||||
let score = 0;
|
||||
let timeDeltaMs: number | null = null;
|
||||
|
||||
const logTimestampMs = line.timestamp ? Date.parse(line.timestamp) : Number.NaN;
|
||||
const detailTimestampMs = detail.__timestampMs;
|
||||
if (!Number.isNaN(logTimestampMs) && detailTimestampMs > 0) {
|
||||
timeDeltaMs = Math.abs(logTimestampMs - detailTimestampMs);
|
||||
if (timeDeltaMs <= TRACE_MATCH_STRONG_WINDOW_MS) {
|
||||
score += 42;
|
||||
} else if (timeDeltaMs <= TRACE_MATCH_WINDOW_MS) {
|
||||
score += 30;
|
||||
} else if (timeDeltaMs <= TRACE_MATCH_MAX_WINDOW_MS) {
|
||||
score += 12;
|
||||
} else {
|
||||
score -= 12;
|
||||
}
|
||||
}
|
||||
|
||||
let methodMatched = false;
|
||||
if (line.method && detail.__endpointMethod) {
|
||||
if (line.method.toUpperCase() === detail.__endpointMethod.toUpperCase()) {
|
||||
score += 18;
|
||||
methodMatched = true;
|
||||
} else {
|
||||
score -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
const logPath = normalizeTracePath(line.path);
|
||||
const detailPath = normalizeTracePath(detail.__endpointPath);
|
||||
let pathMatched = false;
|
||||
if (logPath && detailPath) {
|
||||
if (logPath === detailPath) {
|
||||
score += 24;
|
||||
pathMatched = true;
|
||||
} else if (logPath.startsWith(detailPath) || detailPath.startsWith(logPath)) {
|
||||
score += 12;
|
||||
pathMatched = true;
|
||||
} else {
|
||||
score -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof line.statusCode === 'number') {
|
||||
const logFailed = line.statusCode >= 400;
|
||||
score += logFailed === detail.failed ? 10 : -6;
|
||||
}
|
||||
|
||||
if (timeDeltaMs !== null && timeDeltaMs > TRACE_MATCH_MAX_WINDOW_MS && !methodMatched && !pathMatched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (score <= 0) return null;
|
||||
const confidence: TraceConfidence = score >= 70 ? 'high' : score >= 45 ? 'medium' : 'low';
|
||||
return { detail, score, confidence, timeDeltaMs };
|
||||
};
|
||||
|
||||
const extractLogLevel = (value: string): LogLevel | undefined => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'warning') return 'warn';
|
||||
@@ -351,7 +489,8 @@ export function LogsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const requestLogEnabled = config?.requestLog ?? false;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('logs');
|
||||
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
|
||||
@@ -362,11 +501,19 @@ export function LogsPage() {
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [hideManagementLogs, setHideManagementLogs] = useState(true);
|
||||
const [showRawLogs, setShowRawLogs] = useState(false);
|
||||
const [methodFilters, setMethodFilters] = useState<HttpMethod[]>([]);
|
||||
const [statusFilters, setStatusFilters] = useState<StatusGroup[]>([]);
|
||||
const [pathFilters, setPathFilters] = useState<string[]>([]);
|
||||
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
||||
const [loadingErrors, setLoadingErrors] = useState(false);
|
||||
const [errorLogsError, setErrorLogsError] = useState('');
|
||||
const [requestLogId, setRequestLogId] = useState<string | null>(null);
|
||||
const [requestLogDownloading, setRequestLogDownloading] = useState(false);
|
||||
const [traceLogLine, setTraceLogLine] = useState<ParsedLogLine | null>(null);
|
||||
const [traceUsageDetails, setTraceUsageDetails] = useState<UsageDetailWithEndpoint[]>([]);
|
||||
const [traceAuthFileMap, setTraceAuthFileMap] = useState<Map<string, TraceCredentialInfo>>(new Map());
|
||||
const [traceLoading, setTraceLoading] = useState(false);
|
||||
const [traceError, setTraceError] = useState('');
|
||||
|
||||
const logViewerRef = useRef<HTMLDivElement | null>(null);
|
||||
const pendingScrollToBottomRef = useRef(false);
|
||||
@@ -379,11 +526,77 @@ export function LogsPage() {
|
||||
} | null>(null);
|
||||
const logRequestInFlightRef = useRef(false);
|
||||
const pendingFullReloadRef = useRef(false);
|
||||
const traceUsageLoadedAtRef = useRef(0);
|
||||
const traceAuthLoadedAtRef = useRef(0);
|
||||
|
||||
// 保存最新时间戳用于增量获取
|
||||
const latestTimestampRef = useRef<number>(0);
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
const traceSourceInfoMap = useMemo(() => {
|
||||
const map = new Map<string, TraceSourceInfo>();
|
||||
|
||||
const registerSource = (sourceId: string, displayName: string, type: string) => {
|
||||
if (!sourceId || !displayName || map.has(sourceId)) return;
|
||||
map.set(sourceId, { displayName, type });
|
||||
};
|
||||
|
||||
const registerCandidates = (displayName: string, type: string, candidates: string[]) => {
|
||||
candidates.forEach((sourceId) => registerSource(sourceId, displayName, type));
|
||||
};
|
||||
|
||||
(config?.geminiApiKeys || []).forEach((item, index) => {
|
||||
const displayName = item.prefix?.trim() || `Gemini #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
'gemini',
|
||||
buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix })
|
||||
);
|
||||
});
|
||||
|
||||
(config?.claudeApiKeys || []).forEach((item, index) => {
|
||||
const displayName = item.prefix?.trim() || `Claude #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
'claude',
|
||||
buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix })
|
||||
);
|
||||
});
|
||||
|
||||
(config?.codexApiKeys || []).forEach((item, index) => {
|
||||
const displayName = item.prefix?.trim() || `Codex #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
'codex',
|
||||
buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix })
|
||||
);
|
||||
});
|
||||
|
||||
(config?.vertexApiKeys || []).forEach((item, index) => {
|
||||
const displayName = item.prefix?.trim() || `Vertex #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
'vertex',
|
||||
buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix })
|
||||
);
|
||||
});
|
||||
|
||||
(config?.openaiCompatibility || []).forEach((provider, providerIndex) => {
|
||||
const displayName = provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
|
||||
const candidates = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((sourceId) =>
|
||||
candidates.add(sourceId)
|
||||
);
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((sourceId) =>
|
||||
candidates.add(sourceId)
|
||||
);
|
||||
});
|
||||
registerCandidates(displayName, 'openai', Array.from(candidates));
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [config]);
|
||||
|
||||
const isNearBottom = (node: HTMLDivElement | null) => {
|
||||
if (!node) return true;
|
||||
@@ -551,6 +764,56 @@ export function LogsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadTraceUsageDetails = useCallback(async () => {
|
||||
if (traceLoading) return;
|
||||
|
||||
const now = Date.now();
|
||||
const usageFresh =
|
||||
traceUsageLoadedAtRef.current > 0 && now - traceUsageLoadedAtRef.current < TRACE_USAGE_CACHE_MS;
|
||||
const authFresh =
|
||||
traceAuthLoadedAtRef.current > 0 && now - traceAuthLoadedAtRef.current < TRACE_USAGE_CACHE_MS;
|
||||
if (usageFresh && authFresh) return;
|
||||
|
||||
setTraceLoading(true);
|
||||
setTraceError('');
|
||||
try {
|
||||
const [usageResponse, authFilesResponse] = await Promise.all([
|
||||
usageFresh ? Promise.resolve(null) : usageApi.getUsage(),
|
||||
authFresh ? Promise.resolve(null) : authFilesApi.list().catch(() => null)
|
||||
]);
|
||||
|
||||
if (usageResponse !== null) {
|
||||
const usageData = usageResponse?.usage ?? usageResponse;
|
||||
const details = collectUsageDetailsWithEndpoint(usageData);
|
||||
setTraceUsageDetails(details);
|
||||
traceUsageLoadedAtRef.current = now;
|
||||
}
|
||||
|
||||
if (authFilesResponse !== null) {
|
||||
const files = Array.isArray(authFilesResponse)
|
||||
? authFilesResponse
|
||||
: (authFilesResponse as { files?: AuthFileItem[] })?.files;
|
||||
if (Array.isArray(files)) {
|
||||
const map = new Map<string, TraceCredentialInfo>();
|
||||
files.forEach((file) => {
|
||||
const key = normalizeTraceAuthIndex(file['auth_index'] ?? file.authIndex);
|
||||
if (!key) return;
|
||||
map.set(key, {
|
||||
name: file.name || key,
|
||||
type: (file.type || file.provider || '').toString()
|
||||
});
|
||||
});
|
||||
setTraceAuthFileMap(map);
|
||||
traceAuthLoadedAtRef.current = now;
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setTraceError(getErrorMessage(err) || t('logs.trace_usage_load_error'));
|
||||
} finally {
|
||||
setTraceLoading(false);
|
||||
}
|
||||
}, [t, traceLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionStatus === 'connected') {
|
||||
latestTimestampRef.current = 0;
|
||||
@@ -595,47 +858,173 @@ export function LogsPage() {
|
||||
const isSearching = trimmedSearchQuery.length > 0;
|
||||
const baseLines = isSearching ? logState.buffer : visibleLines;
|
||||
|
||||
const { filteredLines, removedCount } = useMemo(() => {
|
||||
const methodFilterSet = useMemo(() => new Set(methodFilters), [methodFilters]);
|
||||
const statusFilterSet = useMemo(() => new Set(statusFilters), [statusFilters]);
|
||||
const pathFilterSet = useMemo(() => new Set(pathFilters), [pathFilters]);
|
||||
const hasStructuredFilters = methodFilters.length > 0 || statusFilters.length > 0 || pathFilters.length > 0;
|
||||
|
||||
const {
|
||||
parsedSearchLines,
|
||||
filteredParsedLines,
|
||||
filteredLines,
|
||||
removedCount,
|
||||
} = useMemo(() => {
|
||||
let working = baseLines;
|
||||
let removed = 0;
|
||||
|
||||
if (hideManagementLogs) {
|
||||
const next: string[] = [];
|
||||
for (const line of working) {
|
||||
if (line.includes(MANAGEMENT_API_PREFIX)) {
|
||||
removed += 1;
|
||||
} else {
|
||||
next.push(line);
|
||||
}
|
||||
}
|
||||
working = next;
|
||||
working = working.filter((line) => !line.includes(MANAGEMENT_API_PREFIX));
|
||||
}
|
||||
|
||||
if (trimmedSearchQuery) {
|
||||
const queryLowered = trimmedSearchQuery.toLowerCase();
|
||||
const next: string[] = [];
|
||||
for (const line of working) {
|
||||
if (line.toLowerCase().includes(queryLowered)) {
|
||||
next.push(line);
|
||||
} else {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
working = next;
|
||||
working = working.filter((line) => line.toLowerCase().includes(queryLowered));
|
||||
}
|
||||
|
||||
return { filteredLines: working, removedCount: removed };
|
||||
}, [baseLines, hideManagementLogs, trimmedSearchQuery]);
|
||||
const parsed = working.map((line) => parseLogLine(line));
|
||||
const filteredParsed = parsed.filter((line) => {
|
||||
if (methodFilterSet.size > 0 && (!line.method || !methodFilterSet.has(line.method))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedVisibleLines = useMemo(() => {
|
||||
if (showRawLogs) return [];
|
||||
return filteredLines.map((line) => parseLogLine(line));
|
||||
}, [filteredLines, showRawLogs]);
|
||||
const statusGroup = resolveStatusGroup(line.statusCode);
|
||||
if (statusFilterSet.size > 0 && (!statusGroup || !statusFilterSet.has(statusGroup))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pathFilterSet.size > 0 && (!line.path || !pathFilterSet.has(line.path))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
parsedSearchLines: parsed,
|
||||
filteredParsedLines: filteredParsed,
|
||||
filteredLines: filteredParsed.map((line) => line.raw),
|
||||
removedCount: Math.max(baseLines.length - filteredParsed.length, 0)
|
||||
};
|
||||
}, [
|
||||
baseLines,
|
||||
hideManagementLogs,
|
||||
methodFilterSet,
|
||||
pathFilterSet,
|
||||
statusFilterSet,
|
||||
trimmedSearchQuery
|
||||
]);
|
||||
|
||||
const parsedVisibleLines = useMemo(
|
||||
() => (showRawLogs ? [] : filteredParsedLines),
|
||||
[filteredParsedLines, showRawLogs]
|
||||
);
|
||||
|
||||
const rawVisibleText = useMemo(() => filteredLines.join('\n'), [filteredLines]);
|
||||
const traceCandidates = useMemo(() => {
|
||||
if (!traceLogLine) return [];
|
||||
const scored = traceUsageDetails
|
||||
.map((detail) => scoreTraceCandidate(traceLogLine, detail))
|
||||
.filter((item): item is TraceCandidate => item !== null)
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
const aDelta = a.timeDeltaMs ?? Number.MAX_SAFE_INTEGER;
|
||||
const bDelta = b.timeDeltaMs ?? Number.MAX_SAFE_INTEGER;
|
||||
return aDelta - bDelta;
|
||||
});
|
||||
return scored.slice(0, 8);
|
||||
}, [traceLogLine, traceUsageDetails]);
|
||||
const resolveTraceSourceInfo = useCallback(
|
||||
(sourceRaw: string, authIndex: unknown): TraceSourceInfo => {
|
||||
const source = sourceRaw.trim();
|
||||
const matchedSource = traceSourceInfoMap.get(source);
|
||||
if (matchedSource) {
|
||||
return matchedSource;
|
||||
}
|
||||
|
||||
const authIndexKey = normalizeTraceAuthIndex(authIndex);
|
||||
if (authIndexKey) {
|
||||
const authInfo = traceAuthFileMap.get(authIndexKey);
|
||||
if (authInfo) {
|
||||
return {
|
||||
displayName: authInfo.name || authIndexKey,
|
||||
type: authInfo.type
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
displayName: source.startsWith('t:') ? source.slice(2) : source || '-',
|
||||
type: ''
|
||||
};
|
||||
},
|
||||
[traceAuthFileMap, traceSourceInfoMap]
|
||||
);
|
||||
|
||||
const canLoadMore = !isSearching && logState.visibleFrom > 0;
|
||||
|
||||
const methodCounts = useMemo(() => {
|
||||
const counts: Partial<Record<HttpMethod, number>> = {};
|
||||
parsedSearchLines.forEach((line) => {
|
||||
if (!line.method) return;
|
||||
counts[line.method] = (counts[line.method] ?? 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [parsedSearchLines]);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Partial<Record<StatusGroup, number>> = {};
|
||||
parsedSearchLines.forEach((line) => {
|
||||
const statusGroup = resolveStatusGroup(line.statusCode);
|
||||
if (!statusGroup) return;
|
||||
counts[statusGroup] = (counts[statusGroup] ?? 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [parsedSearchLines]);
|
||||
|
||||
const pathOptions = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
parsedSearchLines.forEach((line) => {
|
||||
if (!line.path) return;
|
||||
counts.set(line.path, (counts.get(line.path) ?? 0) + 1);
|
||||
});
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, PATH_FILTER_LIMIT)
|
||||
.map(([path, count]) => ({ path, count }));
|
||||
}, [parsedSearchLines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathFilters.length === 0) return;
|
||||
const validPathSet = new Set(pathOptions.map((item) => item.path));
|
||||
setPathFilters((prev) => {
|
||||
const next = prev.filter((path) => validPathSet.has(path));
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
}, [pathFilters, pathOptions]);
|
||||
|
||||
const toggleMethodFilter = (method: HttpMethod) => {
|
||||
setMethodFilters((prev) =>
|
||||
prev.includes(method) ? prev.filter((item) => item !== method) : [...prev, method]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleStatusFilter = (statusGroup: StatusGroup) => {
|
||||
setStatusFilters((prev) =>
|
||||
prev.includes(statusGroup) ? prev.filter((item) => item !== statusGroup) : [...prev, statusGroup]
|
||||
);
|
||||
};
|
||||
|
||||
const togglePathFilter = (path: string) => {
|
||||
setPathFilters((prev) =>
|
||||
prev.includes(path) ? prev.filter((item) => item !== path) : [...prev, path]
|
||||
);
|
||||
};
|
||||
|
||||
const clearStructuredFilters = () => {
|
||||
setMethodFilters([]);
|
||||
setStatusFilters([]);
|
||||
setPathFilters([]);
|
||||
};
|
||||
|
||||
const prependVisibleLines = useCallback(() => {
|
||||
const node = logViewerRef.current;
|
||||
if (!node) return;
|
||||
@@ -777,6 +1166,18 @@ export function LogsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openTraceModal = (line: ParsedLogLine) => {
|
||||
if (!isTraceableRequestPath(line.path)) return;
|
||||
cancelLongPress();
|
||||
setTraceLogLine(line);
|
||||
void loadTraceUsageDetails();
|
||||
};
|
||||
|
||||
const closeTraceModal = () => {
|
||||
if (requestLogDownloading) return;
|
||||
setTraceLogLine(null);
|
||||
};
|
||||
|
||||
const closeRequestLogModal = () => {
|
||||
if (requestLogDownloading) return;
|
||||
setRequestLogId(null);
|
||||
@@ -866,6 +1267,86 @@ export function LogsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.structuredFilters}>
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_method')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{HTTP_METHODS.map((method) => {
|
||||
const active = methodFilters.includes(method);
|
||||
const count = methodCounts[method] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => toggleMethodFilter(method)}
|
||||
disabled={count === 0 && !active}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{method} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_status')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{STATUS_GROUPS.map((statusGroup) => {
|
||||
const active = statusFilters.includes(statusGroup);
|
||||
const count = statusCounts[statusGroup] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={statusGroup}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => toggleStatusFilter(statusGroup)}
|
||||
disabled={count === 0 && !active}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{t(`logs.filter_status_${statusGroup}`)} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_path')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{pathOptions.length === 0 ? (
|
||||
<span className={styles.filterChipHint}>{t('logs.filter_path_empty')}</span>
|
||||
) : (
|
||||
pathOptions.map(({ path, count }) => {
|
||||
const active = pathFilters.includes(path);
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => togglePathFilter(path)}
|
||||
aria-pressed={active}
|
||||
title={path}
|
||||
>
|
||||
{path} ({count})
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearStructuredFilters}
|
||||
disabled={!hasStructuredFilters}
|
||||
>
|
||||
{t('logs.clear_filters')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch
|
||||
checked={hideManagementLogs}
|
||||
onChange={setHideManagementLogs}
|
||||
@@ -973,6 +1454,7 @@ export function LogsPage() {
|
||||
) : (
|
||||
<div className={styles.logList}>
|
||||
{parsedVisibleLines.map((line, index) => {
|
||||
const canTraceRequest = isTraceableRequestPath(line.path);
|
||||
const rowClassNames = [styles.logRow];
|
||||
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
|
||||
if (line.level === 'error' || line.level === 'fatal')
|
||||
@@ -1063,6 +1545,20 @@ export function LogsPage() {
|
||||
)}
|
||||
|
||||
{line.message && <span className={styles.message}>{line.message}</span>}
|
||||
|
||||
{canTraceRequest && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.traceButton}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openTraceModal(line);
|
||||
}}
|
||||
title={t('logs.trace_button')}
|
||||
>
|
||||
{t('logs.trace_button')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1142,6 +1638,162 @@ export function LogsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={Boolean(traceLogLine)}
|
||||
onClose={closeTraceModal}
|
||||
title={t('logs.trace_title')}
|
||||
footer={
|
||||
<>
|
||||
{traceLogLine?.requestId && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (traceLogLine.requestId) {
|
||||
void downloadRequestLog(traceLogLine.requestId);
|
||||
}
|
||||
}}
|
||||
loading={requestLogDownloading}
|
||||
>
|
||||
{t('logs.trace_download_request_log')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={closeTraceModal} disabled={requestLogDownloading}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{traceLogLine && (
|
||||
<div className={styles.tracePanel}>
|
||||
<div className={styles.traceNotice}>{t('logs.trace_notice')}</div>
|
||||
|
||||
<h3 className={styles.traceSectionTitle}>{t('logs.trace_log_info')}</h3>
|
||||
<div className={styles.traceInfoGrid}>
|
||||
<div className={styles.traceInfoItem}>
|
||||
<span className={styles.traceInfoLabel}>{t('logs.trace_request_id')}</span>
|
||||
<span className={styles.traceInfoValue}>{traceLogLine.requestId || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.traceInfoItem}>
|
||||
<span className={styles.traceInfoLabel}>{t('logs.trace_method')}</span>
|
||||
<span className={styles.traceInfoValue}>{traceLogLine.method || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.traceInfoItem}>
|
||||
<span className={styles.traceInfoLabel}>{t('logs.trace_path')}</span>
|
||||
<span className={styles.traceInfoValue}>{traceLogLine.path || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.traceInfoItem}>
|
||||
<span className={styles.traceInfoLabel}>{t('logs.trace_status_code')}</span>
|
||||
<span className={styles.traceInfoValue}>
|
||||
{typeof traceLogLine.statusCode === 'number' ? traceLogLine.statusCode : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.traceInfoItem}>
|
||||
<span className={styles.traceInfoLabel}>{t('logs.trace_latency')}</span>
|
||||
<span className={styles.traceInfoValue}>{traceLogLine.latency || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.traceInfoItem}>
|
||||
<span className={styles.traceInfoLabel}>{t('logs.trace_ip')}</span>
|
||||
<span className={styles.traceInfoValue}>{traceLogLine.ip || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.traceInfoItem}>
|
||||
<span className={styles.traceInfoLabel}>{t('logs.trace_timestamp')}</span>
|
||||
<span className={styles.traceInfoValue}>{traceLogLine.timestamp || '-'}</span>
|
||||
</div>
|
||||
<div className={`${styles.traceInfoItem} ${styles.traceInfoItemWide}`}>
|
||||
<span className={styles.traceInfoLabel}>{t('logs.trace_message')}</span>
|
||||
<span className={styles.traceInfoValue}>{traceLogLine.message || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className={styles.traceSectionTitle}>{t('logs.trace_candidates_title')}</h3>
|
||||
{traceLoading ? (
|
||||
<div className="hint">{t('logs.trace_loading')}</div>
|
||||
) : traceError ? (
|
||||
<div className="error-box">{traceError}</div>
|
||||
) : traceCandidates.length === 0 ? (
|
||||
<div className="hint">{t('logs.trace_no_match')}</div>
|
||||
) : (
|
||||
<div className={styles.traceCandidates}>
|
||||
{traceCandidates.map((candidate) => {
|
||||
const confidenceClass =
|
||||
candidate.confidence === 'high'
|
||||
? styles.traceConfidenceHigh
|
||||
: candidate.confidence === 'medium'
|
||||
? styles.traceConfidenceMedium
|
||||
: styles.traceConfidenceLow;
|
||||
const sourceInfo = resolveTraceSourceInfo(
|
||||
String(candidate.detail.source ?? ''),
|
||||
candidate.detail.auth_index
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={`${candidate.detail.__endpoint}-${candidate.detail.__modelName}-${candidate.detail.timestamp}-${candidate.detail.source}`}
|
||||
className={styles.traceCandidate}
|
||||
>
|
||||
<div className={styles.traceCandidateHeader}>
|
||||
<span className={`${styles.traceConfidenceBadge} ${confidenceClass}`}>
|
||||
{t(`logs.trace_confidence_${candidate.confidence}`)}
|
||||
</span>
|
||||
<span className={styles.traceScore}>
|
||||
{t('logs.trace_score', { score: candidate.score })}
|
||||
</span>
|
||||
{candidate.timeDeltaMs !== null && (
|
||||
<span className={styles.traceDelta}>
|
||||
{t('logs.trace_delta_seconds', {
|
||||
seconds: (candidate.timeDeltaMs / 1000).toFixed(2)
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.traceCandidateGrid}>
|
||||
<div className={styles.traceInfoItem}>
|
||||
<span className={styles.traceInfoLabel}>{t('logs.trace_endpoint')}</span>
|
||||
<span className={styles.traceInfoValue}>{candidate.detail.__endpoint}</span>
|
||||
</div>
|
||||
<div className={styles.traceInfoItem}>
|
||||
<span className={styles.traceInfoLabel}>{t('logs.trace_model')}</span>
|
||||
<span className={styles.traceInfoValue}>{candidate.detail.__modelName || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.traceInfoItem}>
|
||||
<span className={styles.traceInfoLabel}>{t('logs.trace_source')}</span>
|
||||
<span
|
||||
className={styles.traceInfoValue}
|
||||
title={String(candidate.detail.source || '-')}
|
||||
>
|
||||
<span>{sourceInfo.displayName}</span>
|
||||
{sourceInfo.type && (
|
||||
<span className={styles.traceSourceType}>{sourceInfo.type}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.traceInfoItem}>
|
||||
<span className={styles.traceInfoLabel}>{t('logs.trace_auth_index')}</span>
|
||||
<span className={styles.traceInfoValue}>
|
||||
{candidate.detail.auth_index ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.traceInfoItem}>
|
||||
<span className={styles.traceInfoLabel}>{t('logs.trace_timestamp')}</span>
|
||||
<span className={styles.traceInfoValue}>
|
||||
{candidate.detail.timestamp || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.traceInfoItem}>
|
||||
<span className={styles.traceInfoLabel}>{t('logs.trace_result')}</span>
|
||||
<span className={styles.traceInfoValue}>
|
||||
{candidate.detail.failed ? t('stats.failure') : t('stats.success')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(requestLogId)}
|
||||
onClose={closeRequestLogModal}
|
||||
|
||||
@@ -859,6 +859,98 @@
|
||||
}
|
||||
}
|
||||
|
||||
.requestEventsActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.requestEventsToolbar {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.requestEventsFilterItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.requestEventsFilterLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.requestEventsSelect {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.requestEventsMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-xs;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.requestEventsLimitHint {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.requestEventsTableWrapper {
|
||||
overflow: auto;
|
||||
max-height: 460px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.requestEventsTimestamp {
|
||||
white-space: nowrap;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.requestEventsSourceCell,
|
||||
.requestEventsAuthIndex {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
font-size: 11px;
|
||||
max-width: 180px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.requestEventsResultSuccess {
|
||||
color: var(--success-badge-text, #065f46);
|
||||
background: var(--success-badge-bg, #d1fae5);
|
||||
border: 1px solid var(--success-badge-border, #6ee7b7);
|
||||
}
|
||||
|
||||
.requestEventsResultFailed {
|
||||
color: var(--failure-badge-text);
|
||||
background: var(--failure-badge-bg);
|
||||
border: 1px solid var(--failure-badge-border);
|
||||
}
|
||||
|
||||
.requestEventsResultSuccess,
|
||||
.requestEventsResultFailed {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-full;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chartLineHeader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ModelStatsCard,
|
||||
PriceSettingsCard,
|
||||
CredentialStatsCard,
|
||||
RequestEventsDetailsCard,
|
||||
TokenBreakdownChart,
|
||||
CostTrendChart,
|
||||
ServiceHealthCard,
|
||||
@@ -361,6 +362,16 @@ export function UsagePage() {
|
||||
<ModelStatsCard modelStats={modelStats} loading={loading} hasPrices={hasPrices} />
|
||||
</div>
|
||||
|
||||
<RequestEventsDetailsCard
|
||||
usage={filteredUsage}
|
||||
loading={loading}
|
||||
geminiKeys={config?.geminiApiKeys || []}
|
||||
claudeConfigs={config?.claudeApiKeys || []}
|
||||
codexConfigs={config?.codexApiKeys || []}
|
||||
vertexConfigs={config?.vertexApiKeys || []}
|
||||
openaiProviders={config?.openaiCompatibility || []}
|
||||
/>
|
||||
|
||||
{/* Credential Stats */}
|
||||
<CredentialStatsCard
|
||||
usage={filteredUsage}
|
||||
|
||||
@@ -8,8 +8,13 @@ import { normalizeApiBase } from '@/utils/connection';
|
||||
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
|
||||
|
||||
const DEFAULT_CLAUDE_BASE_URL = 'https://api.anthropic.com';
|
||||
const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com';
|
||||
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
|
||||
const CLAUDE_MODELS_IN_FLIGHT = new Map<string, Promise<ReturnType<typeof normalizeModelList>>>();
|
||||
const GEMINI_MODELS_IN_FLIGHT = new Map<string, Promise<ReturnType<typeof normalizeModelList>>>();
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const buildRequestSignature = (url: string, headers: Record<string, string>) => {
|
||||
const headerSignature = Object.entries(headers)
|
||||
@@ -45,6 +50,21 @@ const buildClaudeModelsEndpoint = (baseUrl: string): string => {
|
||||
return `${trimmed}/v1/models`;
|
||||
};
|
||||
|
||||
const buildGeminiModelsEndpoint = (baseUrl: string): string => {
|
||||
const normalized = normalizeApiBase(baseUrl);
|
||||
const fallback = normalized || DEFAULT_GEMINI_BASE_URL;
|
||||
let trimmed = fallback.replace(/\/+$/g, '');
|
||||
trimmed = trimmed.replace(/\/v1beta\/models$/i, '');
|
||||
trimmed = trimmed.replace(/\/v1beta(?:\/.*)?$/i, '');
|
||||
return `${trimmed}/v1beta/models`;
|
||||
};
|
||||
|
||||
const stripGeminiModelResourceName = (value: string): string => {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed.replace(/^\/?models\//i, '');
|
||||
};
|
||||
|
||||
const hasHeader = (headers: Record<string, string>, name: string) => {
|
||||
const target = name.toLowerCase();
|
||||
return Object.keys(headers).some((key) => key.toLowerCase() === target);
|
||||
@@ -81,6 +101,40 @@ export const modelsApi = {
|
||||
return normalizeModelList(payload, { dedupe: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch models from /v1/models endpoint via api-call.
|
||||
* Useful when the configured baseUrl is the upstream host root (e.g. https://api.example.com).
|
||||
*/
|
||||
async fetchV1ModelsViaApiCall(
|
||||
baseUrl: string,
|
||||
apiKey?: string,
|
||||
headers: Record<string, string> = {}
|
||||
) {
|
||||
const endpoint = buildV1ModelsEndpoint(baseUrl);
|
||||
if (!endpoint) {
|
||||
throw new Error('Invalid base url');
|
||||
}
|
||||
|
||||
const resolvedHeaders = { ...headers };
|
||||
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
|
||||
if (apiKey && !hasAuthHeader) {
|
||||
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
method: 'GET',
|
||||
url: endpoint,
|
||||
header: Object.keys(resolvedHeaders).length ? resolvedHeaders : undefined
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw new Error(getApiCallErrorMessage(result));
|
||||
}
|
||||
|
||||
const payload = result.body ?? result.bodyText;
|
||||
return normalizeModelList(payload, { dedupe: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch models from /models endpoint via api-call (for OpenAI provider discovery)
|
||||
*/
|
||||
@@ -114,10 +168,18 @@ export const modelsApi = {
|
||||
return normalizeModelList(payload, { dedupe: true });
|
||||
},
|
||||
|
||||
buildV1ModelsEndpoint(baseUrl: string) {
|
||||
return buildV1ModelsEndpoint(baseUrl);
|
||||
},
|
||||
|
||||
buildClaudeModelsEndpoint(baseUrl: string) {
|
||||
return buildClaudeModelsEndpoint(baseUrl);
|
||||
},
|
||||
|
||||
buildGeminiModelsEndpoint(baseUrl: string) {
|
||||
return buildGeminiModelsEndpoint(baseUrl);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Claude models from /v1/models via api-call.
|
||||
* Anthropic requires `x-api-key` and `anthropic-version` headers.
|
||||
@@ -171,4 +233,82 @@ export const modelsApi = {
|
||||
CLAUDE_MODELS_IN_FLIGHT.delete(signature);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Gemini models from /v1beta/models via api-call.
|
||||
* Gemini API accepts API key via query param or `x-goog-api-key` header.
|
||||
*/
|
||||
async fetchGeminiModelsViaApiCall(
|
||||
baseUrl: string,
|
||||
apiKey?: string,
|
||||
headers: Record<string, string> = {}
|
||||
) {
|
||||
const endpoint = buildGeminiModelsEndpoint(baseUrl);
|
||||
if (!endpoint) {
|
||||
throw new Error('Invalid base url');
|
||||
}
|
||||
|
||||
const resolvedHeaders = { ...headers };
|
||||
const resolvedApiKey = String(apiKey ?? '').trim();
|
||||
if (resolvedApiKey && !hasHeader(resolvedHeaders, 'x-goog-api-key')) {
|
||||
resolvedHeaders['x-goog-api-key'] = resolvedApiKey;
|
||||
}
|
||||
|
||||
const signature = buildRequestSignature(endpoint, resolvedHeaders);
|
||||
const existing = GEMINI_MODELS_IN_FLIGHT.get(signature);
|
||||
if (existing) return existing;
|
||||
|
||||
const request = (async () => {
|
||||
const seen = new Set<string>();
|
||||
const collected: ReturnType<typeof normalizeModelList> = [];
|
||||
let pageToken = '';
|
||||
|
||||
for (let page = 0; page < 20; page += 1) {
|
||||
const url = new URL(endpoint);
|
||||
if (pageToken) {
|
||||
url.searchParams.set('pageToken', pageToken);
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
method: 'GET',
|
||||
url: url.toString(),
|
||||
header: Object.keys(resolvedHeaders).length ? resolvedHeaders : undefined
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw new Error(getApiCallErrorMessage(result));
|
||||
}
|
||||
|
||||
const payload = result.body ?? result.bodyText;
|
||||
const normalized = normalizeModelList(payload, { dedupe: false });
|
||||
normalized.forEach((model) => {
|
||||
const name = stripGeminiModelResourceName(model.name);
|
||||
const key = (name || '').toLowerCase();
|
||||
if (!key || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
const resolved = { ...model, name };
|
||||
if (resolved.alias && resolved.alias.trim() === name) {
|
||||
resolved.alias = undefined;
|
||||
}
|
||||
collected.push(resolved);
|
||||
});
|
||||
|
||||
const nextToken =
|
||||
isRecord(payload) && typeof payload.nextPageToken === 'string' ? payload.nextPageToken : '';
|
||||
if (!nextToken) {
|
||||
break;
|
||||
}
|
||||
pageToken = nextToken;
|
||||
}
|
||||
|
||||
return collected;
|
||||
})();
|
||||
|
||||
GEMINI_MODELS_IN_FLIGHT.set(signature, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
GEMINI_MODELS_IN_FLIGHT.delete(signature);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -58,8 +58,10 @@ const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
|
||||
|
||||
const serializeProviderKey = (config: ProviderKeyConfig) => {
|
||||
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
|
||||
if (config.priority !== undefined) payload.priority = config.priority;
|
||||
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||
if (config.websockets !== undefined) payload.websockets = config.websockets;
|
||||
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
||||
const headers = serializeHeaders(config.headers);
|
||||
if (headers) payload.headers = headers;
|
||||
@@ -68,6 +70,18 @@ const serializeProviderKey = (config: ProviderKeyConfig) => {
|
||||
if (config.excludedModels && config.excludedModels.length) {
|
||||
payload['excluded-models'] = config.excludedModels;
|
||||
}
|
||||
if (config.cloak) {
|
||||
const cloakPayload: Record<string, unknown> = {};
|
||||
const mode = config.cloak.mode?.trim();
|
||||
if (mode) cloakPayload.mode = mode;
|
||||
if (config.cloak.strictMode !== undefined) cloakPayload['strict-mode'] = config.cloak.strictMode;
|
||||
if (config.cloak.sensitiveWords && config.cloak.sensitiveWords.length) {
|
||||
cloakPayload['sensitive-words'] = config.cloak.sensitiveWords;
|
||||
}
|
||||
if (Object.keys(cloakPayload).length) {
|
||||
payload.cloak = cloakPayload;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
@@ -85,6 +99,7 @@ const serializeVertexModelAliases = (models?: ModelAlias[]) =>
|
||||
|
||||
const serializeVertexKey = (config: ProviderKeyConfig) => {
|
||||
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
|
||||
if (config.priority !== undefined) payload.priority = config.priority;
|
||||
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
||||
@@ -97,11 +112,14 @@ const serializeVertexKey = (config: ProviderKeyConfig) => {
|
||||
|
||||
const serializeGeminiKey = (config: GeminiKeyConfig) => {
|
||||
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
|
||||
if (config.priority !== undefined) payload.priority = config.priority;
|
||||
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
||||
const headers = serializeHeaders(config.headers);
|
||||
if (headers) payload.headers = headers;
|
||||
const models = serializeModelAliases(config.models);
|
||||
if (models && models.length) payload.models = models;
|
||||
if (config.excludedModels && config.excludedModels.length) {
|
||||
payload['excluded-models'] = config.excludedModels;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ApiKeyEntry,
|
||||
CloakConfig,
|
||||
GeminiKeyConfig,
|
||||
ModelAlias,
|
||||
OpenAIProviderConfig,
|
||||
@@ -118,11 +119,20 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null =>
|
||||
if (!trimmed) return null;
|
||||
|
||||
const config: ProviderKeyConfig = { apiKey: trimmed };
|
||||
const priority = record?.priority ?? record?.['priority'];
|
||||
if (priority !== undefined && priority !== null && String(priority).trim() !== '') {
|
||||
const parsed = Number(priority);
|
||||
if (Number.isFinite(parsed)) {
|
||||
config.priority = parsed;
|
||||
}
|
||||
}
|
||||
const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']);
|
||||
if (prefix) config.prefix = prefix;
|
||||
const baseUrl = record ? record['base-url'] ?? record.baseUrl : undefined;
|
||||
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
|
||||
if (baseUrl) config.baseUrl = String(baseUrl);
|
||||
const websockets = normalizeBoolean(record?.websockets ?? record?.['websockets']);
|
||||
if (websockets !== undefined) config.websockets = websockets;
|
||||
if (proxyUrl) config.proxyUrl = String(proxyUrl);
|
||||
const headers = normalizeHeaders(record?.headers);
|
||||
if (headers) config.headers = headers;
|
||||
@@ -135,6 +145,31 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null =>
|
||||
record?.excluded_models
|
||||
);
|
||||
if (excludedModels.length) config.excludedModels = excludedModels;
|
||||
|
||||
const cloakRaw = record?.cloak;
|
||||
if (isRecord(cloakRaw)) {
|
||||
const cloak: CloakConfig = {};
|
||||
const mode = cloakRaw.mode ?? cloakRaw['mode'];
|
||||
if (typeof mode === 'string' && mode.trim()) {
|
||||
cloak.mode = mode.trim();
|
||||
}
|
||||
const strictMode = normalizeBoolean(
|
||||
cloakRaw['strict-mode'] ?? cloakRaw.strictMode ?? cloakRaw.strict_mode
|
||||
);
|
||||
if (strictMode !== undefined) {
|
||||
cloak.strictMode = strictMode;
|
||||
}
|
||||
const sensitiveWords = normalizeExcludedModels(
|
||||
cloakRaw['sensitive-words'] ?? cloakRaw.sensitiveWords ?? cloakRaw.sensitive_words
|
||||
);
|
||||
if (sensitiveWords.length) {
|
||||
cloak.sensitiveWords = sensitiveWords;
|
||||
}
|
||||
if (Object.keys(cloak).length) {
|
||||
config.cloak = cloak;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
@@ -149,12 +184,21 @@ const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
|
||||
if (!trimmed) return null;
|
||||
|
||||
const config: GeminiKeyConfig = { apiKey: trimmed };
|
||||
const priority = record?.priority ?? record?.['priority'];
|
||||
if (priority !== undefined && priority !== null && String(priority).trim() !== '') {
|
||||
const parsed = Number(priority);
|
||||
if (Number.isFinite(parsed)) {
|
||||
config.priority = parsed;
|
||||
}
|
||||
}
|
||||
const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']);
|
||||
if (prefix) config.prefix = prefix;
|
||||
const baseUrl = record ? record['base-url'] ?? record.baseUrl ?? record['base_url'] : undefined;
|
||||
if (baseUrl) config.baseUrl = String(baseUrl);
|
||||
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl ?? record['proxy_url'] : undefined;
|
||||
if (proxyUrl) config.proxyUrl = String(proxyUrl);
|
||||
const models = normalizeModelAliases(record?.models);
|
||||
if (models.length) config.models = models;
|
||||
const headers = normalizeHeaders(record?.headers);
|
||||
if (headers) config.headers = headers;
|
||||
const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels);
|
||||
|
||||
@@ -10,3 +10,5 @@ export { useConfigStore } from './useConfigStore';
|
||||
export { useModelsStore } from './useModelsStore';
|
||||
export { useQuotaStore } from './useQuotaStore';
|
||||
export { useOpenAIEditDraftStore } from './useOpenAIEditDraftStore';
|
||||
export { useClaudeEditDraftStore } from './useClaudeEditDraftStore';
|
||||
export { useUsageStatsStore, USAGE_STATS_STALE_TIME_MS } from './useUsageStatsStore';
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Claude provider editor draft state.
|
||||
*
|
||||
* Why this exists:
|
||||
* - The app uses `PageTransition` with iOS-style stacked routes for `/ai-providers/*`.
|
||||
* - Entering `/ai-providers/claude/.../models` creates a new route layer, so component-local state
|
||||
* inside the Claude edit layout is not shared between the edit screen and the model picker screen.
|
||||
* - This store makes the Claude edit draft shared across route layers keyed by provider index/new.
|
||||
*/
|
||||
|
||||
import type { SetStateAction } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import type { ProviderFormState } from '@/components/providers/types';
|
||||
|
||||
export type ClaudeTestStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
type ClaudeEditDraft = {
|
||||
initialized: boolean;
|
||||
baselineSignature: string;
|
||||
form: ProviderFormState;
|
||||
testModel: string;
|
||||
testStatus: ClaudeTestStatus;
|
||||
testMessage: string;
|
||||
};
|
||||
|
||||
interface ClaudeEditDraftState {
|
||||
drafts: Record<string, ClaudeEditDraft>;
|
||||
refCounts: Record<string, number>;
|
||||
acquireDraft: (key: string) => void;
|
||||
releaseDraft: (key: string) => void;
|
||||
ensureDraft: (key: string) => void;
|
||||
initDraft: (
|
||||
key: string,
|
||||
draft: Omit<ClaudeEditDraft, 'initialized'>
|
||||
) => void;
|
||||
setDraftBaselineSignature: (key: string, signature: string) => void;
|
||||
setDraftForm: (
|
||||
key: string,
|
||||
action: SetStateAction<ProviderFormState>
|
||||
) => void;
|
||||
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
|
||||
setDraftTestStatus: (
|
||||
key: string,
|
||||
action: SetStateAction<ClaudeTestStatus>
|
||||
) => void;
|
||||
setDraftTestMessage: (key: string, action: SetStateAction<string>) => void;
|
||||
clearDraft: (key: string) => void;
|
||||
}
|
||||
|
||||
const resolveAction = <T,>(action: SetStateAction<T>, prev: T): T =>
|
||||
typeof action === 'function' ? (action as (previous: T) => T)(prev) : action;
|
||||
|
||||
const buildEmptyForm = (): ProviderFormState => ({
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
headers: [],
|
||||
models: [],
|
||||
excludedModels: [],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
const buildEmptyDraft = (): ClaudeEditDraft => ({
|
||||
initialized: false,
|
||||
baselineSignature: '',
|
||||
form: buildEmptyForm(),
|
||||
testModel: '',
|
||||
testStatus: 'idle',
|
||||
testMessage: '',
|
||||
});
|
||||
|
||||
export const useClaudeEditDraftStore = create<ClaudeEditDraftState>((set, get) => ({
|
||||
drafts: {},
|
||||
refCounts: {},
|
||||
|
||||
acquireDraft: (key) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const existingDraft = state.drafts[key];
|
||||
const currentCount = state.refCounts[key] ?? 0;
|
||||
return {
|
||||
drafts: existingDraft ? state.drafts : { ...state.drafts, [key]: buildEmptyDraft() },
|
||||
refCounts: { ...state.refCounts, [key]: currentCount + 1 },
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
releaseDraft: (key) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const currentCount = state.refCounts[key];
|
||||
if (!currentCount) return state;
|
||||
if (currentCount > 1) {
|
||||
return { refCounts: { ...state.refCounts, [key]: currentCount - 1 } };
|
||||
}
|
||||
const nextCounts = { ...state.refCounts };
|
||||
delete nextCounts[key];
|
||||
const nextDrafts = { ...state.drafts };
|
||||
delete nextDrafts[key];
|
||||
return { refCounts: nextCounts, drafts: nextDrafts };
|
||||
});
|
||||
},
|
||||
|
||||
ensureDraft: (key) => {
|
||||
if (!key) return;
|
||||
const existing = get().drafts[key];
|
||||
if (existing) return;
|
||||
set((state) => ({
|
||||
drafts: { ...state.drafts, [key]: buildEmptyDraft() },
|
||||
}));
|
||||
},
|
||||
|
||||
initDraft: (key, draft) => {
|
||||
if (!key) return;
|
||||
const existing = get().drafts[key];
|
||||
if (existing?.initialized) return;
|
||||
set((state) => ({
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: { ...draft, initialized: true },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setDraftBaselineSignature: (key, signature) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const existing = state.drafts[key] ?? buildEmptyDraft();
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: { ...existing, initialized: true, baselineSignature: signature },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setDraftForm: (key, action) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const existing = state.drafts[key] ?? buildEmptyDraft();
|
||||
const nextForm = resolveAction(action, existing.form);
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: { ...existing, initialized: true, form: nextForm },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setDraftTestModel: (key, action) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const existing = state.drafts[key] ?? buildEmptyDraft();
|
||||
const nextValue = resolveAction(action, existing.testModel);
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: { ...existing, initialized: true, testModel: nextValue },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setDraftTestStatus: (key, action) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const existing = state.drafts[key] ?? buildEmptyDraft();
|
||||
const nextValue = resolveAction(action, existing.testStatus);
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: { ...existing, initialized: true, testStatus: nextValue },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setDraftTestMessage: (key, action) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const existing = state.drafts[key] ?? buildEmptyDraft();
|
||||
const nextValue = resolveAction(action, existing.testMessage);
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: { ...existing, initialized: true, testMessage: nextValue },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearDraft: (key) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
if (!state.drafts[key] && !state.refCounts[key]) return state;
|
||||
const nextDrafts = { ...state.drafts };
|
||||
delete nextDrafts[key];
|
||||
const nextCounts = { ...state.refCounts };
|
||||
delete nextCounts[key];
|
||||
return { drafts: nextDrafts, refCounts: nextCounts };
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -22,6 +22,7 @@ export type KeyTestStatus = {
|
||||
|
||||
export type OpenAIEditDraft = {
|
||||
initialized: boolean;
|
||||
baselineSignature: string;
|
||||
form: OpenAIFormState;
|
||||
testModel: string;
|
||||
testStatus: OpenAITestStatus;
|
||||
@@ -31,8 +32,12 @@ export type OpenAIEditDraft = {
|
||||
|
||||
interface OpenAIEditDraftState {
|
||||
drafts: Record<string, OpenAIEditDraft>;
|
||||
refCounts: Record<string, number>;
|
||||
acquireDraft: (key: string) => void;
|
||||
releaseDraft: (key: string) => void;
|
||||
ensureDraft: (key: string) => void;
|
||||
initDraft: (key: string, draft: Omit<OpenAIEditDraft, 'initialized'>) => void;
|
||||
setDraftBaselineSignature: (key: string, signature: string) => void;
|
||||
setDraftForm: (key: string, action: SetStateAction<OpenAIFormState>) => void;
|
||||
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
|
||||
setDraftTestStatus: (key: string, action: SetStateAction<OpenAITestStatus>) => void;
|
||||
@@ -57,6 +62,7 @@ const buildEmptyForm = (): OpenAIFormState => ({
|
||||
|
||||
const buildEmptyDraft = (): OpenAIEditDraft => ({
|
||||
initialized: false,
|
||||
baselineSignature: '',
|
||||
form: buildEmptyForm(),
|
||||
testModel: '',
|
||||
testStatus: 'idle',
|
||||
@@ -66,6 +72,35 @@ const buildEmptyDraft = (): OpenAIEditDraft => ({
|
||||
|
||||
export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) => ({
|
||||
drafts: {},
|
||||
refCounts: {},
|
||||
|
||||
acquireDraft: (key) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const existingDraft = state.drafts[key];
|
||||
const currentCount = state.refCounts[key] ?? 0;
|
||||
return {
|
||||
drafts: existingDraft ? state.drafts : { ...state.drafts, [key]: buildEmptyDraft() },
|
||||
refCounts: { ...state.refCounts, [key]: currentCount + 1 },
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
releaseDraft: (key) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const currentCount = state.refCounts[key];
|
||||
if (!currentCount) return state;
|
||||
if (currentCount > 1) {
|
||||
return { refCounts: { ...state.refCounts, [key]: currentCount - 1 } };
|
||||
}
|
||||
const nextCounts = { ...state.refCounts };
|
||||
delete nextCounts[key];
|
||||
const nextDrafts = { ...state.drafts };
|
||||
delete nextDrafts[key];
|
||||
return { refCounts: nextCounts, drafts: nextDrafts };
|
||||
});
|
||||
},
|
||||
|
||||
ensureDraft: (key) => {
|
||||
if (!key) return;
|
||||
@@ -88,6 +123,19 @@ export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) =
|
||||
}));
|
||||
},
|
||||
|
||||
setDraftBaselineSignature: (key, signature) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const existing = state.drafts[key] ?? buildEmptyDraft();
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: { ...existing, initialized: true, baselineSignature: signature },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setDraftForm: (key, action) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
@@ -179,11 +227,12 @@ export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) =
|
||||
clearDraft: (key) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
if (!state.drafts[key]) return state;
|
||||
const next = { ...state.drafts };
|
||||
delete next[key];
|
||||
return { drafts: next };
|
||||
if (!state.drafts[key] && !state.refCounts[key]) return state;
|
||||
const nextDrafts = { ...state.drafts };
|
||||
delete nextDrafts[key];
|
||||
const nextCounts = { ...state.refCounts };
|
||||
delete nextCounts[key];
|
||||
return { drafts: nextDrafts, refCounts: nextCounts };
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { create } from 'zustand';
|
||||
import { usageApi } from '@/services/api';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { collectUsageDetails, computeKeyStats, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
export const USAGE_STATS_STALE_TIME_MS = 240_000;
|
||||
|
||||
export type LoadUsageStatsOptions = {
|
||||
force?: boolean;
|
||||
staleTimeMs?: number;
|
||||
};
|
||||
|
||||
type UsageStatsSnapshot = Record<string, unknown>;
|
||||
|
||||
type UsageStatsState = {
|
||||
usage: UsageStatsSnapshot | null;
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastRefreshedAt: number | null;
|
||||
scopeKey: string;
|
||||
loadUsageStats: (options?: LoadUsageStatsOptions) => Promise<void>;
|
||||
clearUsageStats: () => void;
|
||||
};
|
||||
|
||||
const createEmptyKeyStats = (): KeyStats => ({ bySource: {}, byAuthIndex: {} });
|
||||
|
||||
let usageRequestToken = 0;
|
||||
let inFlightUsageRequest: { id: number; scopeKey: string; promise: Promise<void> } | null = null;
|
||||
|
||||
const getErrorMessage = (error: unknown) =>
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: i18n.t('usage_stats.loading_error');
|
||||
|
||||
export const useUsageStatsStore = create<UsageStatsState>((set, get) => ({
|
||||
usage: null,
|
||||
keyStats: createEmptyKeyStats(),
|
||||
usageDetails: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastRefreshedAt: null,
|
||||
scopeKey: '',
|
||||
|
||||
loadUsageStats: async (options = {}) => {
|
||||
const force = options.force === true;
|
||||
const staleTimeMs = options.staleTimeMs ?? USAGE_STATS_STALE_TIME_MS;
|
||||
const { apiBase = '', managementKey = '' } = useAuthStore.getState();
|
||||
const scopeKey = `${apiBase}::${managementKey}`;
|
||||
const state = get();
|
||||
const scopeChanged = state.scopeKey !== scopeKey;
|
||||
|
||||
// 先复用同源 in-flight 请求,避免多个页面同时发起重复 /usage。
|
||||
if (inFlightUsageRequest && inFlightUsageRequest.scopeKey === scopeKey) {
|
||||
await inFlightUsageRequest.promise;
|
||||
return;
|
||||
}
|
||||
|
||||
// 连接目标变化时,旧请求结果必须失效。
|
||||
if (inFlightUsageRequest && inFlightUsageRequest.scopeKey !== scopeKey) {
|
||||
usageRequestToken += 1;
|
||||
inFlightUsageRequest = null;
|
||||
}
|
||||
|
||||
const fresh =
|
||||
!scopeChanged &&
|
||||
state.lastRefreshedAt !== null &&
|
||||
Date.now() - state.lastRefreshedAt < staleTimeMs;
|
||||
|
||||
if (!force && fresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scopeChanged) {
|
||||
set({
|
||||
usage: null,
|
||||
keyStats: createEmptyKeyStats(),
|
||||
usageDetails: [],
|
||||
error: null,
|
||||
lastRefreshedAt: null,
|
||||
scopeKey
|
||||
});
|
||||
}
|
||||
|
||||
const requestId = (usageRequestToken += 1);
|
||||
set({ loading: true, error: null, scopeKey });
|
||||
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
const usageResponse = await usageApi.getUsage();
|
||||
const rawUsage = usageResponse?.usage ?? usageResponse;
|
||||
const usage =
|
||||
rawUsage && typeof rawUsage === 'object' ? (rawUsage as UsageStatsSnapshot) : null;
|
||||
|
||||
if (requestId !== usageRequestToken) return;
|
||||
|
||||
set({
|
||||
usage,
|
||||
keyStats: computeKeyStats(usage),
|
||||
usageDetails: collectUsageDetails(usage),
|
||||
loading: false,
|
||||
error: null,
|
||||
lastRefreshedAt: Date.now(),
|
||||
scopeKey
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (requestId !== usageRequestToken) return;
|
||||
set({
|
||||
loading: false,
|
||||
error: getErrorMessage(error),
|
||||
scopeKey
|
||||
});
|
||||
} finally {
|
||||
if (inFlightUsageRequest?.id === requestId) {
|
||||
inFlightUsageRequest = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightUsageRequest = { id: requestId, scopeKey, promise: requestPromise };
|
||||
await requestPromise;
|
||||
},
|
||||
|
||||
clearUsageStats: () => {
|
||||
usageRequestToken += 1;
|
||||
inFlightUsageRequest = null;
|
||||
set({
|
||||
usage: null,
|
||||
keyStats: createEmptyKeyStats(),
|
||||
usageDetails: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastRefreshedAt: null,
|
||||
scopeKey: ''
|
||||
});
|
||||
}
|
||||
}));
|
||||
@@ -25,6 +25,10 @@ export interface AuthFileItem {
|
||||
authIndex?: string | number | null;
|
||||
runtimeOnly?: boolean | string;
|
||||
disabled?: boolean;
|
||||
unavailable?: boolean;
|
||||
status?: string;
|
||||
statusMessage?: string;
|
||||
lastRefresh?: string | number;
|
||||
modified?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -16,23 +16,34 @@ export interface ApiKeyEntry {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CloakConfig {
|
||||
mode?: string;
|
||||
strictMode?: boolean;
|
||||
sensitiveWords?: string[];
|
||||
}
|
||||
|
||||
export interface GeminiKeyConfig {
|
||||
apiKey: string;
|
||||
priority?: number;
|
||||
prefix?: string;
|
||||
baseUrl?: string;
|
||||
proxyUrl?: string;
|
||||
models?: ModelAlias[];
|
||||
headers?: Record<string, string>;
|
||||
excludedModels?: string[];
|
||||
}
|
||||
|
||||
export interface ProviderKeyConfig {
|
||||
apiKey: string;
|
||||
priority?: number;
|
||||
prefix?: string;
|
||||
baseUrl?: string;
|
||||
websockets?: boolean;
|
||||
proxyUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
models?: ModelAlias[];
|
||||
excludedModels?: string[];
|
||||
cloak?: CloakConfig;
|
||||
}
|
||||
|
||||
export interface OpenAIProviderConfig {
|
||||
|
||||
@@ -134,8 +134,8 @@ export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
|
||||
{
|
||||
id: 'gemini-pro-series',
|
||||
label: 'Gemini Pro Series',
|
||||
preferredModelId: 'gemini-3-pro-preview',
|
||||
modelIds: ['gemini-3-pro-preview', 'gemini-2.5-pro'],
|
||||
preferredModelId: 'gemini-3-1-pro-preview',
|
||||
modelIds: ['gemini-3-1-pro-preview', 'gemini-3-pro-preview', 'gemini-2.5-pro'],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -51,6 +51,13 @@ export interface UsageDetail {
|
||||
__modelName?: string;
|
||||
}
|
||||
|
||||
export interface UsageDetailWithEndpoint extends UsageDetail {
|
||||
__endpoint: string;
|
||||
__endpointMethod?: string;
|
||||
__endpointPath?: string;
|
||||
__timestampMs: number;
|
||||
}
|
||||
|
||||
export interface ApiStats {
|
||||
endpoint: string;
|
||||
totalRequests: number;
|
||||
@@ -65,6 +72,7 @@ export type UsageTimeRange = '7h' | '24h' | '7d' | 'all';
|
||||
|
||||
const TOKENS_PER_PRICE_UNIT = 1_000_000;
|
||||
const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
|
||||
const USAGE_ENDPOINT_METHOD_REGEX = /^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+(\S+)/i;
|
||||
const USAGE_TIME_RANGE_MS: Record<Exclude<UsageTimeRange, 'all'>, number> = {
|
||||
'7h': 7 * 60 * 60 * 1000,
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
@@ -474,6 +482,49 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
|
||||
return details;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从使用数据中收集包含 endpoint/method/path 的请求明细
|
||||
*/
|
||||
export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetailWithEndpoint[] {
|
||||
const apis = getApisRecord(usageData);
|
||||
if (!apis) return [];
|
||||
|
||||
const details: UsageDetailWithEndpoint[] = [];
|
||||
Object.entries(apis).forEach(([endpoint, apiEntry]) => {
|
||||
if (!isRecord(apiEntry)) return;
|
||||
const modelsRaw = apiEntry.models;
|
||||
const models = isRecord(modelsRaw) ? modelsRaw : null;
|
||||
if (!models) return;
|
||||
|
||||
const endpointMatch = endpoint.match(USAGE_ENDPOINT_METHOD_REGEX);
|
||||
const endpointMethod = endpointMatch?.[1]?.toUpperCase();
|
||||
const endpointPath = endpointMatch?.[2];
|
||||
|
||||
Object.entries(models).forEach(([modelName, modelEntry]) => {
|
||||
if (!isRecord(modelEntry)) return;
|
||||
const modelDetailsRaw = modelEntry.details;
|
||||
const modelDetails = Array.isArray(modelDetailsRaw) ? modelDetailsRaw : [];
|
||||
|
||||
modelDetails.forEach((detailRaw) => {
|
||||
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
|
||||
const detail = detailRaw as unknown as UsageDetail;
|
||||
const timestampMs = Date.parse(detail.timestamp);
|
||||
details.push({
|
||||
...detail,
|
||||
source: normalizeUsageSourceId(detail.source),
|
||||
__modelName: modelName,
|
||||
__endpoint: endpoint,
|
||||
__endpointMethod: endpointMethod,
|
||||
__endpointPath: endpointPath,
|
||||
__timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从单条明细提取总 tokens
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user