Compare commits

...

8 Commits

36 changed files with 1372 additions and 452 deletions
+6 -11
View File
@@ -213,7 +213,6 @@ export function MainLayout() {
const logout = useAuthStore((state) => state.logout);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache);
@@ -431,16 +430,12 @@ export function MainLayout() {
metaKey: 'nav_meta.quota_management',
icon: sidebarIcons.quota,
},
...(config?.loggingToFile
? [
{
path: '/logs',
labelKey: 'nav.logs',
metaKey: 'nav_meta.logs',
icon: sidebarIcons.logs,
},
]
: []),
{
path: '/logs',
labelKey: 'nav.logs',
metaKey: 'nav_meta.logs',
icon: sidebarIcons.logs,
},
],
},
{
+61
View File
@@ -10,6 +10,7 @@ import {
} from '@/utils/recentRequests';
const DISABLE_ALL_MODELS_RULE = '*';
const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com';
export const hasDisableAllModelsRule = (models?: string[]) =>
Array.isArray(models) &&
@@ -52,6 +53,33 @@ const normalizeClaudeBaseUrl = (baseUrl: string): string => {
return trimmed;
};
const normalizeGeminiBaseUrl = (baseUrl: string): string => {
let trimmed = String(baseUrl || '').trim();
if (!trimmed) {
return DEFAULT_GEMINI_BASE_URL;
}
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
trimmed = trimmed.replace(/\/+$/g, '');
if (!/^https?:\/\//i.test(trimmed)) {
trimmed = `http://${trimmed}`;
}
return trimmed;
};
const buildGeminiModelResource = (model: string): string => {
const trimmed = String(model || '')
.trim()
.replace(/^\/+/g, '')
.replace(/:generateContent$/i, '');
if (!trimmed) return '';
if (/^(models|tunedModels)\//i.test(trimmed)) {
return trimmed.split('/').map(encodeURIComponent).join('/');
}
return `models/${encodeURIComponent(trimmed)}`;
};
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
if (!trimmed) return '';
@@ -73,6 +101,30 @@ export const buildClaudeMessagesEndpoint = (baseUrl: string): string => {
return `${trimmed}/v1/messages`;
};
export const buildGeminiGenerateContentEndpoint = (
baseUrl: string,
model: string
): string => {
const resource = buildGeminiModelResource(model);
if (!resource) return '';
const trimmed = normalizeGeminiBaseUrl(baseUrl);
if (!trimmed) return '';
if (/:generateContent$/i.test(trimmed)) {
return trimmed;
}
let root = trimmed.replace(/\/+$/g, '');
if (/\/v1beta\/models$/i.test(root)) {
root = root.replace(/\/models$/i, '');
} else if (!/\/v1beta$/i.test(root)) {
root = root.replace(/\/v1beta(?:\/.*)?$/i, '');
root = `${root}/v1beta`;
}
return `${root}/${resource}:generateContent`;
};
export type ProviderRecentUsageMap = Map<string, Map<string, RecentRequestUsageEntry>>;
const EMPTY_RECENT_USAGE_ENTRY: RecentRequestUsageEntry = {
@@ -128,6 +180,15 @@ export function getProviderTotalStats(
return { success: entry.success, failure: entry.failed };
}
export function getProviderRecentWindowStats(
usageByProvider: ProviderRecentUsageMap,
provider: string,
apiKey?: string,
baseUrl?: string
): { success: number; failure: number } {
return sumRecentRequests(getProviderRecentBuckets(usageByProvider, provider, apiKey, baseUrl));
}
const collectOpenAIProviderRecentBuckets = (
provider: OpenAIProviderConfig,
usageByProvider: ProviderRecentUsageMap
+22
View File
@@ -182,6 +182,28 @@ export function IconTrash2({ size = 20, ...props }: IconProps) {
);
}
export function IconMaximize2({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M15 3h6v6" />
<path d="m21 3-7 7" />
<path d="M9 21H3v-6" />
<path d="m3 21 7-7" />
</svg>
);
}
export function IconMinimize2({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M4 14h6v6" />
<path d="m10 14-7 7" />
<path d="M20 10h-6V4" />
<path d="m14 10 7-7" />
</svg>
);
}
export function IconPlus({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
+31 -4
View File
@@ -11,6 +11,7 @@ import iconKimiLight from '@/assets/icons/kimi-light.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconVertex from '@/assets/icons/vertex.svg';
import type { AuthFileItem } from '@/types';
import { normalizeOAuthProviderKey } from '@/utils/providerKeys';
import { parseTimestamp } from '@/utils/timestamp';
export type ThemeColors = { bg: string; text: string; border?: string };
@@ -35,6 +36,19 @@ export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>([
'xai',
]);
export const OAUTH_PROVIDER_PRESETS = [
'gemini-cli',
'vertex',
'aistudio',
'antigravity',
'xai',
'claude',
'codex',
'kimi',
];
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
export const MIN_CARD_PAGE_SIZE = 3;
export const MAX_CARD_PAGE_SIZE = 30;
export const AUTH_FILE_REFRESH_WARNING_MS = 24 * 60 * 60 * 1000;
@@ -137,10 +151,23 @@ export const resolveQuotaErrorMessage = (
return fallback;
};
export const normalizeProviderKey = (value: string) => {
const key = value.trim().toLowerCase().replace(/_/g, '-');
if (key === 'x-ai' || key === 'grok') return 'xai';
return key;
export const normalizeProviderKey = normalizeOAuthProviderKey;
export const buildOAuthProviderOptions = (values: Iterable<unknown>): string[] => {
const extraProviders = new Set<string>();
Array.from(values).forEach((value) => {
const key = normalizeProviderKey(String(value ?? ''));
if (!key || OAUTH_PROVIDER_EXCLUDES.has(key)) return;
extraProviders.add(key);
});
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => normalizeProviderKey(value)));
const extraList = Array.from(extraProviders)
.filter((value) => !baseSet.has(value))
.sort((a, b) => a.localeCompare(b));
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
};
export const getAuthFileStatusMessage = (file: AuthFileItem): string => {
+107 -63
View File
@@ -5,16 +5,20 @@ import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { Skeleton } from '@/components/ui/Skeleton';
import { useAuthStore, useNotificationStore } from '@/stores';
import { useProviderRecentRequests } from '@/components/providers/hooks/useProviderRecentRequests';
import { getOpenAIProviderRecentWindowStats } from '@/components/providers/utils';
import {
getOpenAIProviderRecentWindowStats,
getProviderRecentWindowStats,
type ProviderRecentUsageMap,
} from '@/components/providers/utils';
import type { OpenAIProviderConfig } from '@/types';
import { ProviderHeaderCard } from './components/ProviderHeaderCard';
import { ProviderCategoryList } from './components/ProviderCategoryList';
import { ProviderResourcePanel } from './components/ProviderResourcePanel';
import type { OpenAIPanelControls } from './components/ProviderResourcePanel';
import type { ProviderPanelControls } from './components/ProviderResourcePanel';
import type {
OpenAISortBy,
ProviderSortBy,
SortDir,
} from './components/OpenAIBrandToolbar';
} from './components/ProviderResourceToolbar';
import { ProviderSheet, type ProviderSheetHandle } from './sheets/ProviderSheet';
import { useProviderWorkbench } from './useProviderWorkbench';
import type { ProviderBrand, ProviderResource } from './types';
@@ -59,6 +63,63 @@ const matchesFilter = (r: ProviderResource, normalized: string): boolean => {
return haystack.some((v) => v.includes(normalized));
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
const getResourceModels = (resource: ProviderResource): string[] => {
if (!isRecord(resource.raw)) return [];
if (resource.brand === 'ampcode') {
const mappings = resource.raw.modelMappings;
if (!Array.isArray(mappings)) return [];
const seen = new Set<string>();
mappings.forEach((mapping) => {
if (!isRecord(mapping)) return;
const from = typeof mapping.from === 'string' ? mapping.from.trim() : '';
const to = typeof mapping.to === 'string' ? mapping.to.trim() : '';
if (from) seen.add(from);
if (to) seen.add(to);
});
return Array.from(seen);
}
const models = resource.raw.models;
if (!Array.isArray(models)) return [];
const seen = new Set<string>();
models.forEach((model) => {
if (!isRecord(model)) return;
const name = typeof model.name === 'string' ? model.name.trim() : '';
if (name) seen.add(name);
});
return Array.from(seen);
};
const getResourcePriority = (resource: ProviderResource): number => {
if (!isRecord(resource.raw)) return 0;
const priority = resource.raw.priority;
return typeof priority === 'number' && Number.isFinite(priority) ? priority : 0;
};
const getResourceSortName = (resource: ProviderResource): string =>
(resource.name ?? resource.identifier ?? resource.apiKeyPreview ?? '').toLowerCase();
const getResourceRecentSuccess = (
resource: ProviderResource,
usageByProvider: ProviderRecentUsageMap
): number => {
if (resource.brand === 'openaiCompatibility') {
return getOpenAIProviderRecentWindowStats(
resource.raw as OpenAIProviderConfig,
usageByProvider
).success;
}
if (resource.brand === 'ampcode') return 0;
return getProviderRecentWindowStats(
usageByProvider,
resource.brand,
resource.apiKey ?? undefined,
resource.baseUrl ?? undefined
).success;
};
export function ProvidersWorkbenchPage() {
const { t, i18n } = useTranslation();
const connectionStatus = useAuthStore((s) => s.connectionStatus);
@@ -70,11 +131,9 @@ export function ProvidersWorkbenchPage() {
const workbench = useProviderWorkbench();
const [activeBrand, setActiveBrand] = useState<ProviderBrand>('gemini');
const [filter, setFilter] = useState('');
const [openaiSortBy, setOpenaiSortBy] = useState<OpenAISortBy>('name');
const [openaiSortDir, setOpenaiSortDir] = useState<SortDir>('asc');
const [openaiSelectedModels, setOpenaiSelectedModels] = useState<Set<string>>(
() => new Set()
);
const [providerSortBy, setProviderSortBy] = useState<ProviderSortBy>('name');
const [providerSortDir, setProviderSortDir] = useState<SortDir>('asc');
const [selectedModels, setSelectedModels] = useState<Set<string>>(() => new Set());
const [sheetState, setSheetState] = useState<SheetState>({
open: false,
brand: 'gemini',
@@ -109,85 +168,69 @@ export function ProvidersWorkbenchPage() {
return activeGroup.resources.filter((r) => matchesFilter(r, normalized));
}, [activeGroup, filter]);
const isOpenAI = activeGroup?.id === 'openaiCompatibility';
const availableOpenaiModels = useMemo(() => {
if (!isOpenAI || !activeGroup) return [];
const availableModels = useMemo(() => {
if (!activeGroup) return [];
const seen = new Set<string>();
activeGroup.resources.forEach((r) => {
const cfg = r.raw as OpenAIProviderConfig;
cfg.models?.forEach((m) => {
const name = (m.name ?? '').trim();
if (name) seen.add(name);
});
getResourceModels(r).forEach((name) => seen.add(name));
});
return Array.from(seen).sort();
}, [activeGroup, isOpenAI]);
}, [activeGroup]);
const visibleResources = useMemo(() => {
if (!isOpenAI) return filteredResources;
let arr = filteredResources;
if (openaiSelectedModels.size > 0) {
if (selectedModels.size > 0) {
arr = arr.filter((r) => {
const cfg = r.raw as OpenAIProviderConfig;
return Boolean(
cfg.models?.some((m) => openaiSelectedModels.has((m.name ?? '').trim()))
);
const models = getResourceModels(r);
return models.some((name) => selectedModels.has(name));
});
}
const sorted = [...arr].sort((a, b) => {
let diff = 0;
if (openaiSortBy === 'name') {
const an = (a.name ?? a.identifier ?? '').toLowerCase();
const bn = (b.name ?? b.identifier ?? '').toLowerCase();
diff = an.localeCompare(bn);
} else if (openaiSortBy === 'priority') {
const ap = (a.raw as OpenAIProviderConfig).priority ?? 0;
const bp = (b.raw as OpenAIProviderConfig).priority ?? 0;
if (providerSortBy === 'name') {
diff = getResourceSortName(a).localeCompare(getResourceSortName(b));
} else if (providerSortBy === 'priority') {
const ap = getResourcePriority(a);
const bp = getResourcePriority(b);
diff = ap - bp;
} else {
const aStats = getOpenAIProviderRecentWindowStats(
a.raw as OpenAIProviderConfig,
usageByProvider
);
const bStats = getOpenAIProviderRecentWindowStats(
b.raw as OpenAIProviderConfig,
usageByProvider
);
diff = aStats.success - bStats.success;
diff =
getResourceRecentSuccess(a, usageByProvider) -
getResourceRecentSuccess(b, usageByProvider);
}
return openaiSortDir === 'asc' ? diff : -diff;
if (diff === 0) {
diff = a.originalIndex - b.originalIndex;
}
return providerSortDir === 'asc' ? diff : -diff;
});
return sorted;
}, [
filteredResources,
isOpenAI,
openaiSelectedModels,
openaiSortBy,
openaiSortDir,
providerSortBy,
providerSortDir,
selectedModels,
usageByProvider,
]);
const openaiControls = useMemo<OpenAIPanelControls | undefined>(() => {
if (!isOpenAI) return undefined;
const toolbarControls = useMemo<ProviderPanelControls | undefined>(() => {
if (!activeGroup) return undefined;
return {
sortBy: openaiSortBy,
sortDir: openaiSortDir,
onSortBy: setOpenaiSortBy,
onSortDir: setOpenaiSortDir,
availableModels: availableOpenaiModels,
selectedModels: openaiSelectedModels,
onSelectedModelsChange: setOpenaiSelectedModels,
sortBy: providerSortBy,
sortDir: providerSortDir,
onSortBy: setProviderSortBy,
onSortDir: setProviderSortDir,
availableModels,
selectedModels,
onSelectedModelsChange: setSelectedModels,
};
}, [
availableOpenaiModels,
isOpenAI,
openaiSelectedModels,
openaiSortBy,
openaiSortDir,
activeGroup,
availableModels,
providerSortBy,
providerSortDir,
selectedModels,
]);
const totalResources = useMemo(
@@ -381,7 +424,7 @@ export function ProvidersWorkbenchPage() {
if (!ok) return;
setActiveBrand(brand);
setFilter('');
setOpenaiSelectedModels(new Set());
setSelectedModels(new Set());
if (isSwitching) {
closeSheet();
}
@@ -396,7 +439,7 @@ export function ProvidersWorkbenchPage() {
selectedId={sheetState.open ? sheetState.resource?.id ?? null : null}
disableMutations={disableMutations}
usageByProvider={usageByProvider}
openaiControls={openaiControls}
toolbarControls={toolbarControls}
onView={openView}
onEdit={openEdit}
onDelete={handleDelete}
@@ -417,6 +460,7 @@ export function ProvidersWorkbenchPage() {
workbench={workbench}
onCreated={handleCreated}
onUpdated={handleUpdated}
mutationDisabled={disableMutations}
usageByProvider={usageByProvider}
/>
</div>
@@ -10,10 +10,10 @@ import type { ProviderRecentUsageMap } from '@/components/providers/utils';
import type { ProviderBrand, ProviderGroup, ProviderResource } from '../types';
import { ProviderResourceTable } from './ProviderResourceTable';
import {
OpenAIBrandToolbar,
type OpenAISortBy,
ProviderResourceToolbar,
type ProviderSortBy,
type SortDir,
} from './OpenAIBrandToolbar';
} from './ProviderResourceToolbar';
import styles from './ProviderResourcePanel.module.scss';
const LOGOS: Record<ProviderBrand, { src: string; invertOnDark?: boolean }> = {
@@ -25,10 +25,10 @@ const LOGOS: Record<ProviderBrand, { src: string; invertOnDark?: boolean }> = {
ampcode: { src: ampcodeLogo },
};
export interface OpenAIPanelControls {
sortBy: OpenAISortBy;
export interface ProviderPanelControls {
sortBy: ProviderSortBy;
sortDir: SortDir;
onSortBy: (value: OpenAISortBy) => void;
onSortBy: (value: ProviderSortBy) => void;
onSortDir: (value: SortDir) => void;
availableModels: ReadonlyArray<string>;
selectedModels: ReadonlySet<string>;
@@ -43,7 +43,7 @@ interface ProviderResourcePanelProps {
selectedId: string | null;
disableMutations?: boolean;
usageByProvider?: ProviderRecentUsageMap;
openaiControls?: OpenAIPanelControls;
toolbarControls?: ProviderPanelControls;
onView: (resource: ProviderResource) => void;
onEdit: (resource: ProviderResource) => void;
onDelete: (resource: ProviderResource) => void;
@@ -59,7 +59,7 @@ export function ProviderResourcePanel({
selectedId,
disableMutations,
usageByProvider,
openaiControls,
toolbarControls,
onView,
onEdit,
onDelete,
@@ -105,16 +105,17 @@ export function ProviderResourcePanel({
</div>
) : null}
</div>
{openaiControls ? (
{toolbarControls ? (
<div className={styles.headerToolbarRow}>
<OpenAIBrandToolbar
sortBy={openaiControls.sortBy}
sortDir={openaiControls.sortDir}
onSortBy={openaiControls.onSortBy}
onSortDir={openaiControls.onSortDir}
availableModels={openaiControls.availableModels}
selectedModels={openaiControls.selectedModels}
onSelectedModelsChange={openaiControls.onSelectedModelsChange}
<ProviderResourceToolbar
key={group.id}
sortBy={toolbarControls.sortBy}
sortDir={toolbarControls.sortDir}
onSortBy={toolbarControls.onSortBy}
onSortDir={toolbarControls.onSortDir}
availableModels={toolbarControls.availableModels}
selectedModels={toolbarControls.selectedModels}
onSelectedModelsChange={toolbarControls.onSelectedModelsChange}
/>
</div>
) : null}
@@ -7,22 +7,22 @@ import {
} from '@/components/ui/icons';
import { Select } from '@/components/ui/Select';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import styles from './OpenAIBrandToolbar.module.scss';
import styles from './ProviderResourceToolbar.module.scss';
export type OpenAISortBy = 'name' | 'priority' | 'recent-success';
export type ProviderSortBy = 'name' | 'priority' | 'recent-success';
export type SortDir = 'asc' | 'desc';
interface OpenAIBrandToolbarProps {
sortBy: OpenAISortBy;
interface ProviderResourceToolbarProps {
sortBy: ProviderSortBy;
sortDir: SortDir;
onSortBy: (value: OpenAISortBy) => void;
onSortBy: (value: ProviderSortBy) => void;
onSortDir: (value: SortDir) => void;
availableModels: ReadonlyArray<string>;
selectedModels: ReadonlySet<string>;
onSelectedModelsChange: (next: Set<string>) => void;
}
export function OpenAIBrandToolbar({
export function ProviderResourceToolbar({
sortBy,
sortDir,
onSortBy,
@@ -30,7 +30,7 @@ export function OpenAIBrandToolbar({
availableModels,
selectedModels,
onSelectedModelsChange,
}: OpenAIBrandToolbarProps) {
}: ProviderResourceToolbarProps) {
const { t } = useTranslation();
const [filterOpen, setFilterOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
@@ -86,7 +86,7 @@ export function OpenAIBrandToolbar({
<Select
value={sortBy}
options={sortOptions}
onChange={(value) => onSortBy(value as OpenAISortBy)}
onChange={(value) => onSortBy(value as ProviderSortBy)}
ariaLabel={t('providersPage.toolbar.sortBy')}
size="sm"
/>
+1 -1
View File
@@ -36,7 +36,7 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
supportsHeaders: true,
supportsExcludedModels: true,
supportsPriority: true,
supportsTestModel: false,
supportsTestModel: true,
supportsWebsockets: false,
supportsCloak: false,
supportsApiKeyEntries: false,
@@ -36,6 +36,7 @@ interface ProviderSheetProps {
workbench: UseProviderWorkbenchResult;
onCreated: () => void;
onUpdated: () => void;
mutationDisabled?: boolean;
usageByProvider?: ProviderRecentUsageMap;
ref?: Ref<ProviderSheetHandle>;
}
@@ -47,6 +48,7 @@ export function ProviderSheet({
workbench,
onCreated,
onUpdated,
mutationDisabled = false,
usageByProvider,
ref,
}: ProviderSheetProps) {
@@ -70,6 +72,8 @@ export function ProviderSheet({
const descriptor = PROVIDER_DESCRIPTORS[state.brand];
const isAmpcode = state.brand === 'ampcode';
const isEditingForm = state.mode === 'create' || state.mode === 'edit';
const formMutating = submitting || mutationDisabled;
const submitDisabled = formMutating || (state.mode === 'edit' && !isDirty);
const confirmDiscardIfDirty = useCallback((): Promise<boolean> => {
if (!isEditingForm || !isDirty || submitting) {
@@ -111,6 +115,7 @@ export function ProviderSheet({
const handleCreate = useCallback(
async (input: ProviderEntryFormInput) => {
if (mutationDisabled) return;
setSubmitting(true);
try {
await workbench.createProvider(state.brand, input);
@@ -119,12 +124,12 @@ export function ProviderSheet({
setSubmitting(false);
}
},
[onCreated, state.brand, workbench]
[mutationDisabled, onCreated, state.brand, workbench]
);
const handleUpdate = useCallback(
async (input: ProviderEntryFormInput) => {
if (!state.resource) return;
if (!state.resource || mutationDisabled || !isDirty) return;
setSubmitting(true);
try {
await workbench.updateProvider(state.resource, input);
@@ -133,11 +138,12 @@ export function ProviderSheet({
setSubmitting(false);
}
},
[onUpdated, state.resource, workbench]
[isDirty, mutationDisabled, onUpdated, state.resource, workbench]
);
const handleAmpcodeSubmit = useCallback(
async (config: Parameters<UseProviderWorkbenchResult['saveAmpcode']>[0]) => {
if (mutationDisabled || !isDirty) return;
setSubmitting(true);
try {
await workbench.saveAmpcode(config);
@@ -146,7 +152,7 @@ export function ProviderSheet({
setSubmitting(false);
}
},
[onUpdated, workbench]
[isDirty, mutationDisabled, onUpdated, workbench]
);
const renderBody = () => {
@@ -162,7 +168,7 @@ export function ProviderSheet({
<AmpcodeForm
key={formKey}
resource={state.resource}
mutating={submitting || workbench.mutating}
mutating={formMutating}
formId={formId}
onSubmit={handleAmpcodeSubmit}
onDirtyChange={handleDirtyChange}
@@ -175,7 +181,7 @@ export function ProviderSheet({
brand={state.brand as Exclude<ProviderBrand, 'ampcode'>}
resource={state.resource}
mode={state.mode}
mutating={submitting || workbench.mutating}
mutating={formMutating}
formId={formId}
onSubmit={state.mode === 'create' ? handleCreate : handleUpdate}
onDirtyChange={handleDirtyChange}
@@ -198,6 +204,7 @@ export function ProviderSheet({
type="button"
className={`${styles.footerBtn} ${styles.footerBtnPrimary}`}
onClick={onSwitchToEdit}
disabled={formMutating}
>
<IconPencil size={14} />
{t('providersPage.actions.edit')}
@@ -226,7 +233,7 @@ export function ProviderSheet({
type="submit"
form={formId}
className={`${styles.footerBtn} ${styles.footerBtnPrimary}`}
disabled={submitting}
disabled={submitDisabled}
>
{submitting ? (
<IconLoader2 size={14} />
@@ -76,7 +76,10 @@ function buildInitialForm(
websockets: brand === 'codex' ? false : undefined,
cloak:
brand === 'claude' ? { mode: '', strictMode: false, sensitiveWordsText: '' } : undefined,
testModel: brand === 'openaiCompatibility' || brand === 'claude' ? '' : undefined,
testModel:
brand === 'openaiCompatibility' || brand === 'claude' || brand === 'gemini'
? ''
: undefined,
apiKeyEntries: brand === 'openaiCompatibility' ? [emptyApiKeyEntry()] : undefined,
};
}
@@ -152,7 +155,7 @@ function buildInitialForm(
sensitiveWordsText: (cfg as ProviderKeyConfig).cloak?.sensitiveWords?.join('\n') ?? '',
}
: undefined,
testModel: brand === 'claude' ? '' : undefined,
testModel: brand === 'claude' || brand === 'gemini' ? '' : undefined,
};
}
@@ -381,13 +384,6 @@ export function BaseProviderForm({
if (descriptor.baseUrlRequired && !form.baseUrl.trim()) {
return t('providersPage.form.validation.baseUrlRequired');
}
if (
brand === 'openaiCompatibility' &&
mode === 'create' &&
!form.apiKeyEntries?.some((e) => e.apiKey.trim())
) {
return t('providersPage.form.validation.apiKeyRequired');
}
return null;
};
@@ -421,6 +417,13 @@ export function BaseProviderForm({
form.apiKeyEntries && form.apiKeyEntries.length ? form.apiKeyEntries : [emptyApiKeyEntry()],
[form.apiKeyEntries]
);
const actualApiKeyEntries = form.apiKeyEntries ?? [];
const singleConnectivity =
brand === 'gemini'
? { status: connectivity.geminiStatus, run: connectivity.runGemini }
: brand === 'claude'
? { status: connectivity.claudeStatus, run: connectivity.runClaude }
: null;
const removeApiKeyEntry = (removeIdx: number) => {
setShowPasswords((prev) => {
@@ -437,7 +440,7 @@ export function BaseProviderForm({
});
updateField(
'apiKeyEntries',
apiKeyEntries.filter((_, i) => i !== removeIdx)
actualApiKeyEntries.filter((_, i) => i !== removeIdx)
);
};
@@ -584,7 +587,7 @@ export function BaseProviderForm({
<div className={styles.field}>
<label className={styles.label} htmlFor={`${fid}-testModel`}>
{t('providersPage.form.testModel')}
{brand === 'claude' ? (
{brand === 'claude' || brand === 'gemini' ? (
<span className={styles.labelHint}>
{' '}
· {t('providersPage.form.testModelClaudeHint')}
@@ -599,31 +602,31 @@ export function BaseProviderForm({
disabled={mutating}
ariaLabel={t('providersPage.form.testModel')}
/>
{brand === 'claude' ? (
{singleConnectivity ? (
<div className={styles.connectivityRow}>
<button
type="button"
className={styles.connectivityBtn}
disabled={mutating || connectivity.isTestingAny}
onClick={() => void connectivity.runClaude()}
onClick={() => void singleConnectivity.run()}
>
{connectivity.claudeStatus.state === 'loading' ? (
{singleConnectivity.status.state === 'loading' ? (
<span className={`${styles.statusIcon} ${styles.statusIconLoading}`}>
<IconLoader2 size={14} />
</span>
) : null}
<span>{t('providersPage.connectivity.test')}</span>
</button>
<ConnectivityStatusIcon state={connectivity.claudeStatus.state} />
{connectivity.claudeStatus.state === 'success' ? (
<ConnectivityStatusIcon state={singleConnectivity.status.state} />
{singleConnectivity.status.state === 'success' ? (
<span className={styles.connectivityHintSuccess}>
{t('providersPage.connectivity.success')}
</span>
) : null}
</div>
) : null}
{brand === 'claude' && connectivity.claudeStatus.state === 'error' ? (
<div className={styles.connectivityError}>{connectivity.claudeStatus.message}</div>
{singleConnectivity?.status.state === 'error' ? (
<div className={styles.connectivityError}>{singleConnectivity.status.message}</div>
) : null}
</div>
) : null}
@@ -676,7 +679,9 @@ export function BaseProviderForm({
type="button"
className={styles.addBtn}
disabled={mutating}
onClick={() => updateField('apiKeyEntries', [...apiKeyEntries, emptyApiKeyEntry()])}
onClick={() =>
updateField('apiKeyEntries', [...actualApiKeyEntries, emptyApiKeyEntry()])
}
>
<IconPlus size={12} />
<span>{t('providersPage.form.addApiKeyEntry')}</span>
@@ -724,7 +729,7 @@ export function BaseProviderForm({
<button
type="button"
className={styles.removeBtn}
disabled={mutating || apiKeyEntries.length <= 1}
disabled={mutating || actualApiKeyEntries.length === 0}
onClick={() => removeApiKeyEntry(realIdx)}
>
<IconX size={12} />
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import {
buildClaudeMessagesEndpoint,
buildGeminiGenerateContentEndpoint,
buildOpenAIChatCompletionsEndpoint,
} from '@/components/providers/utils';
import { buildHeaderObject, hasHeader } from '@/utils/headers';
@@ -25,6 +26,18 @@ const errorMessage = (err: unknown): string => {
return '';
};
const requestFailureMessage = (err: unknown, messages: ConnectivityErrorMessages): string => {
const raw = errorMessage(err);
const isTimeout =
(typeof err === 'object' &&
err !== null &&
'code' in err &&
String((err as { code?: string }).code) === 'ECONNABORTED') ||
raw.toLowerCase().includes('timeout');
return isTimeout ? messages.timeout(DEFAULT_TIMEOUT_MS / 1000) : raw || messages.requestFailed;
};
const pickModel = (testModel: string | undefined, models: ModelEntryInput[]): string => {
const trimmed = (testModel ?? '').trim();
if (trimmed) return trimmed;
@@ -65,10 +78,12 @@ export interface ConnectivityErrorMessages {
export interface UseConnectivityTestResult {
openaiStatuses: ConnectivityStatus[];
geminiStatus: ConnectivityStatus;
claudeStatus: ConnectivityStatus;
isTestingAny: boolean;
runOpenAIKey: (idx: number) => Promise<boolean>;
runOpenAIAllKeys: () => Promise<void>;
runGemini: () => Promise<void>;
runClaude: () => Promise<void>;
}
@@ -93,6 +108,7 @@ export function useConnectivityTest(
const [openaiStatuses, setOpenaiStatuses] = useState<ConnectivityStatus[]>(() =>
Array.from({ length: entriesCount }, () => IDLE)
);
const [geminiStatus, setGeminiStatus] = useState<ConnectivityStatus>(IDLE);
const [claudeStatus, setClaudeStatus] = useState<ConnectivityStatus>(IDLE);
const [inFlight, setInFlight] = useState(0);
@@ -133,14 +149,23 @@ export function useConnectivityTest(
const signature = useMemo(() => {
const h = formHeaders.map((it) => `${it.key}:${it.value}`).join('|');
const m = models.map((it) => `${it.name}:${it.alias ?? ''}`).join('|');
return `${baseUrl}||${(testModel ?? '').trim()}||${h}||${m}`;
}, [baseUrl, testModel, formHeaders, models]);
return [
baseUrl,
(testModel ?? '').trim(),
apiKey ?? '',
fallbackApiKey ?? '',
authIndex ?? '',
h,
m,
].join('||');
}, [apiKey, authIndex, baseUrl, fallbackApiKey, testModel, formHeaders, models]);
const lastSignatureRef = useRef(signature);
useEffect(() => {
if (lastSignatureRef.current === signature) return;
lastSignatureRef.current = signature;
setOpenaiStatuses((prev) => prev.map(() => IDLE));
setGeminiStatus(IDLE);
setClaudeStatus(IDLE);
}, [signature]);
@@ -228,18 +253,9 @@ export function useConnectivityTest(
updateOpenaiStatus(idx, { state: 'success', message: '' });
return true;
} catch (err) {
const raw = errorMessage(err);
const isTimeout =
(typeof err === 'object' &&
err !== null &&
'code' in err &&
String((err as { code?: string }).code) === 'ECONNABORTED') ||
raw.toLowerCase().includes('timeout');
updateOpenaiStatus(idx, {
state: 'error',
message: isTimeout
? messages.timeout(DEFAULT_TIMEOUT_MS / 1000)
: raw || messages.requestFailed,
message: requestFailureMessage(err, messages),
});
return false;
} finally {
@@ -266,6 +282,75 @@ export function useConnectivityTest(
await Promise.all(entries.map((_, idx) => runOpenAIKey(idx)));
}, [apiKeyEntries, brand, runOpenAIKey]);
const runGemini = useCallback(async (): Promise<void> => {
if (brand !== 'gemini') return;
const model = pickModel(testModel, models);
if (!model) {
setGeminiStatus({ state: 'error', message: messages.modelRequired });
return;
}
const endpoint = buildGeminiGenerateContentEndpoint(baseUrl ?? '', model);
if (!endpoint) {
setGeminiStatus({ state: 'error', message: messages.endpointInvalid });
return;
}
const customHeaders = buildHeaderObject(formHeaders);
const explicitKey = (apiKey ?? '').trim();
const persistedKey = (fallbackApiKey ?? '').trim();
const hasApiKeyHeader = hasHeader(customHeaders, 'x-goog-api-key');
const resolvedKey = explicitKey || persistedKey;
const resolvedAuthIndex = (authIndex ?? '').trim() || undefined;
if (!resolvedKey && !hasApiKeyHeader && !resolvedAuthIndex) {
setGeminiStatus({ state: 'error', message: messages.apiKeyRequired });
return;
}
const headerObj: Record<string, string> = {
'Content-Type': 'application/json',
...customHeaders,
};
if (!hasHeader(headerObj, 'x-goog-api-key')) {
if (resolvedKey) {
headerObj['x-goog-api-key'] = resolvedKey;
} else if (resolvedAuthIndex) {
headerObj['x-goog-api-key'] = '$TOKEN$';
}
}
setGeminiStatus({ state: 'loading', message: '' });
setInFlight((n) => n + 1);
try {
const result = await apiCallApi.request(
{
authIndex: resolvedAuthIndex,
method: 'POST',
url: endpoint,
header: headerObj,
data: JSON.stringify({
contents: [{ parts: [{ text: 'Hi' }] }],
generationConfig: { maxOutputTokens: 8 },
}),
},
{ timeout: DEFAULT_TIMEOUT_MS }
);
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
setGeminiStatus({ state: 'success', message: '' });
} catch (err) {
setGeminiStatus({
state: 'error',
message: requestFailureMessage(err, messages),
});
} finally {
setInFlight((n) => n - 1);
}
}, [apiKey, authIndex, baseUrl, brand, fallbackApiKey, formHeaders, messages, models, testModel]);
const runClaude = useCallback(async (): Promise<void> => {
if (brand !== 'claude') return;
@@ -328,18 +413,9 @@ export function useConnectivityTest(
}
setClaudeStatus({ state: 'success', message: '' });
} catch (err) {
const raw = errorMessage(err);
const isTimeout =
(typeof err === 'object' &&
err !== null &&
'code' in err &&
String((err as { code?: string }).code) === 'ECONNABORTED') ||
raw.toLowerCase().includes('timeout');
setClaudeStatus({
state: 'error',
message: isTimeout
? messages.timeout(DEFAULT_TIMEOUT_MS / 1000)
: raw || messages.requestFailed,
message: requestFailureMessage(err, messages),
});
} finally {
setInFlight((n) => n - 1);
@@ -348,10 +424,12 @@ export function useConnectivityTest(
return {
openaiStatuses,
geminiStatus,
claudeStatus,
isTestingAny: inFlight > 0,
runOpenAIKey,
runOpenAIAllKeys,
runGemini,
runClaude,
};
}
+1 -1
View File
@@ -121,7 +121,7 @@ export interface ProviderEntryFormInput {
websockets?: boolean;
/** Claude 专属 */
cloak?: CloakInput;
/** OpenAI 专属 */
/** OpenAI persists this; Gemini/Claude use it for one-off connectivity tests. */
testModel?: string;
apiKeyEntries?: ApiKeyEntryInput[];
}
@@ -161,7 +161,7 @@ const buildOpenAIConfig = (
name: input.name.trim(),
baseUrl: input.baseUrl.trim(),
prefix: input.prefix.trim() || undefined,
apiKeyEntries: apiKeyEntries.length ? apiKeyEntries : (existing?.apiKeyEntries ?? []),
apiKeyEntries,
disabled: input.disabled,
headers: Object.keys(headers).length ? headers : undefined,
models: models.length ? models : undefined,
@@ -443,7 +443,7 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
updateConfigValue('vertex-api-key', next);
clearCache('vertex-api-key');
} else if (sel.brand === 'openaiCompatibility') {
await providersApi.deleteOpenAIProvider(sel.name);
await providersApi.deleteOpenAIProvider(sel.index);
const next = (config?.openaiCompatibility ?? []).filter((_, i) => i !== sel.index);
updateConfigValue('openai-compatibility', next);
clearCache('openai-compatibility');
+15 -1
View File
@@ -143,7 +143,7 @@
"quick_actions": "Quick Actions",
"current_config": "Current Configuration",
"management_keys": "Management Keys",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"oauth_credentials": "OAuth Credentials",
"edit_settings": "Edit Settings",
"routing_strategy": "Routing Strategy",
@@ -562,6 +562,7 @@
"alias_label": "Model aliases",
"alias_name_placeholder": "Source model name",
"alias_placeholder": "Alias (required)",
"duplicate_alias": "Each alias must be unique for this provider.",
"alias_fork_label": "Keep original",
"add_alias": "Add alias",
"save": "Save/Update",
@@ -730,12 +731,18 @@
},
"logs": {
"title": "Logs Viewer",
"runtime_unknown": "Current runtime: detecting",
"runtime_cpa": "Current runtime: CPA",
"runtime_home": "Current runtime: Home",
"refresh_button": "Refresh Logs",
"clear_button": "Clear Logs",
"fullscreen_button": "Full Screen",
"exit_fullscreen_button": "Exit Full Screen",
"download_button": "Download Logs",
"error_log_button": "Select Error Log",
"error_logs_modal_title": "Error Request Logs",
"error_logs_description": "Pick an error request log file to download (only generated when request logging is off).",
"error_logs_home_unavailable": "In Home mode, use Log Content for database logs. Error request log files only apply to CPA file logs.",
"error_logs_request_log_enabled": "Request logging is enabled, so this list will always be empty. Disable request logging and refresh to view error logs.",
"error_logs_empty": "No error request log files found",
"error_logs_load_error": "Failed to load error log list",
@@ -748,6 +755,13 @@
"request_log_download_success": "Request log downloaded successfully",
"empty_title": "No Logs Available",
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
"cpa_file_logging_required": "The connected runtime is CPA. Log viewing requires logging to file to be enabled first.",
"cpa_file_logging_required_title": "File logging is required",
"cpa_file_logging_required_desc": "CPA reads this log view from local log files. Enable logging-to-file in the config, then refresh.",
"file_logging_required": "This logs endpoint requires logging to file before file logs can be read.",
"file_logging_required_title": "File logging is required",
"file_logging_required_desc": "This logs endpoint reads local log files. Enable logging-to-file in the config, then refresh.",
"home_clear_unavailable": "Home logs come from database queries and cannot be cleared here.",
"log_content": "Log Content",
"loading": "Loading logs...",
"load_error": "Failed to load logs",
+15 -1
View File
@@ -143,7 +143,7 @@
"quick_actions": "Быстрые действия",
"current_config": "Текущая конфигурация",
"management_keys": "Ключи управления",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"oauth_credentials": "Учётные данные OAuth",
"edit_settings": "Изменить настройки",
"routing_strategy": "Стратегия маршрутизации",
@@ -559,6 +559,7 @@
"alias_label": "Псевдонимы моделей",
"alias_name_placeholder": "Исходное имя модели",
"alias_placeholder": "Псевдоним (обязательно)",
"duplicate_alias": "Каждый псевдоним этого провайдера должен быть уникальным.",
"alias_fork_label": "Сохранить оригинал",
"add_alias": "Добавить псевдоним",
"save": "Сохранить/обновить",
@@ -727,12 +728,18 @@
},
"logs": {
"title": "Просмотр журналов",
"runtime_unknown": "Текущий режим: определяется",
"runtime_cpa": "Текущий режим: CPA",
"runtime_home": "Текущий режим: Home",
"refresh_button": "Обновить журналы",
"clear_button": "Очистить журналы",
"fullscreen_button": "На весь экран",
"exit_fullscreen_button": "Выйти из полноэкранного режима",
"download_button": "Скачать журналы",
"error_log_button": "Выбрать журнал ошибок",
"error_logs_modal_title": "Журналы ошибок запросов",
"error_logs_description": "Выберите файл журнала ошибок запроса для скачивания (создаётся только при отключённом журналировании запросов).",
"error_logs_home_unavailable": "В режиме Home смотрите журналы базы данных на вкладке содержимого. Файлы ошибок запросов относятся только к файловым журналам CPA.",
"error_logs_request_log_enabled": "Журналирование запросов включено, поэтому этот список всегда будет пустым. Отключите журналирование запросов и обновите список, чтобы просмотреть журналы ошибок.",
"error_logs_empty": "Файлы журнала ошибок запросов не найдены",
"error_logs_load_error": "Не удалось загрузить список журналов ошибок",
@@ -745,6 +752,13 @@
"request_log_download_success": "Журнал запросов успешно скачан",
"empty_title": "Журналы недоступны",
"empty_desc": "Когда включена опция \"Включить журналирование в файл\", журналы появятся здесь",
"cpa_file_logging_required": "Подключён режим CPA. Для просмотра журналов сначала включите запись журналов в файл.",
"cpa_file_logging_required_title": "Требуется файловое журналирование",
"cpa_file_logging_required_desc": "CPA читает этот журнал из локальных файлов. Включите logging-to-file в конфигурации и обновите страницу.",
"file_logging_required": "Этот endpoint журналов требует включить запись журналов в файл перед чтением файловых журналов.",
"file_logging_required_title": "Требуется файловое журналирование",
"file_logging_required_desc": "Этот endpoint журналов читает локальные файлы. Включите logging-to-file в конфигурации и обновите страницу.",
"home_clear_unavailable": "Журналы Home загружаются из базы данных и не могут быть очищены здесь.",
"log_content": "Содержимое журнала",
"loading": "Загрузка журналов...",
"load_error": "Не удалось загрузить журналы",
+15 -1
View File
@@ -143,7 +143,7 @@
"quick_actions": "快捷操作",
"current_config": "当前配置",
"management_keys": "管理密钥",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"oauth_credentials": "OAuth 凭证",
"edit_settings": "编辑设置",
"routing_strategy": "路由策略",
@@ -562,6 +562,7 @@
"alias_label": "模型别名",
"alias_name_placeholder": "原模型名称",
"alias_placeholder": "别名 (必填)",
"duplicate_alias": "同一提供商下的别名不能重复。",
"alias_fork_label": "保留原名",
"add_alias": "添加别名",
"save": "保存/更新",
@@ -730,12 +731,18 @@
},
"logs": {
"title": "日志查看",
"runtime_unknown": "当前运行端:识别中",
"runtime_cpa": "当前运行端:CPA",
"runtime_home": "当前运行端:Home",
"refresh_button": "刷新日志",
"clear_button": "清空日志",
"fullscreen_button": "全屏显示",
"exit_fullscreen_button": "退出全屏",
"download_button": "下载日志",
"error_log_button": "选择错误日志",
"error_logs_modal_title": "错误请求日志",
"error_logs_description": "请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。",
"error_logs_home_unavailable": "Home 模式下请在日志内容页查看数据库日志,错误请求日志列表仅适用于 CPA 文件日志。",
"error_logs_request_log_enabled": "当前已开启请求日志,按接口约定错误请求日志列表会始终为空。关闭请求日志后再刷新即可查看。",
"error_logs_empty": "暂无错误请求日志文件",
"error_logs_load_error": "加载错误日志列表失败",
@@ -748,6 +755,13 @@
"request_log_download_success": "报文下载成功",
"empty_title": "暂无日志记录",
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
"cpa_file_logging_required": "当前连接的是 CPA,日志查看需要先开启“日志记录到文件”。",
"cpa_file_logging_required_title": "需要开启文件日志",
"cpa_file_logging_required_desc": "CPA 的日志接口读取本地日志文件。请在配置中开启 logging-to-file 后再刷新查看。",
"file_logging_required": "当前日志接口需要先开启“日志记录到文件”后才能读取文件日志。",
"file_logging_required_title": "需要开启文件日志",
"file_logging_required_desc": "当前日志接口读取本地日志文件。请在配置中开启 logging-to-file 后再刷新查看。",
"home_clear_unavailable": "Home 日志来自数据库查询,当前不支持从这里清空。",
"log_content": "日志内容",
"loading": "正在加载日志...",
"load_error": "加载日志失败",
+15 -1
View File
@@ -143,7 +143,7 @@
"quick_actions": "快速操作",
"current_config": "目前設定",
"management_keys": "管理金鑰",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"oauth_credentials": "OAuth 憑證",
"edit_settings": "編輯設定",
"routing_strategy": "路由策略",
@@ -562,6 +562,7 @@
"alias_label": "模型別名",
"alias_name_placeholder": "原模型名稱",
"alias_placeholder": "別名(必填)",
"duplicate_alias": "同一供應商下的別名不能重複。",
"alias_fork_label": "保留原名",
"add_alias": "新增別名",
"save": "儲存/更新",
@@ -756,12 +757,18 @@
},
"logs": {
"title": "記錄檢視",
"runtime_unknown": "目前執行端:識別中",
"runtime_cpa": "目前執行端:CPA",
"runtime_home": "目前執行端:Home",
"refresh_button": "重新整理記錄",
"clear_button": "清空記錄",
"fullscreen_button": "全螢幕顯示",
"exit_fullscreen_button": "退出全螢幕",
"download_button": "下載記錄",
"error_log_button": "選擇錯誤記錄",
"error_logs_modal_title": "錯誤請求記錄",
"error_logs_description": "請選擇要下載的錯誤請求記錄檔案(僅在關閉請求記錄時產生)。",
"error_logs_home_unavailable": "Home 模式下請在記錄內容頁查看資料庫記錄,錯誤請求記錄清單僅適用於 CPA 檔案記錄。",
"error_logs_request_log_enabled": "目前已開啟請求記錄,按介面約定錯誤請求記錄清單會始終為空。關閉請求記錄後再重新整理即可查看。",
"error_logs_empty": "暫無錯誤請求記錄檔案",
"error_logs_load_error": "載入錯誤記錄清單失敗",
@@ -774,6 +781,13 @@
"request_log_download_success": "封包下載成功",
"empty_title": "暫無記錄",
"empty_desc": "當啟用「記錄到檔案」功能後,記錄將顯示在這裡",
"cpa_file_logging_required": "目前連線的是 CPA,記錄檢視需要先開啟「記錄到檔案」。",
"cpa_file_logging_required_title": "需要開啟檔案記錄",
"cpa_file_logging_required_desc": "CPA 的記錄介面會讀取本機記錄檔。請在設定中開啟 logging-to-file 後再重新整理查看。",
"file_logging_required": "目前記錄介面需要先開啟「記錄到檔案」後才能讀取檔案記錄。",
"file_logging_required_title": "需要開啟檔案記錄",
"file_logging_required_desc": "目前記錄介面會讀取本機記錄檔。請在設定中開啟 logging-to-file 後再重新整理查看。",
"home_clear_unavailable": "Home 記錄來自資料庫查詢,目前不支援從這裡清空。",
"log_content": "記錄內容",
"loading": "正在載入記錄...",
"load_error": "載入記錄失敗",
+7 -30
View File
@@ -12,6 +12,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useAuthStore, useNotificationStore } from '@/stores';
import { authFilesApi } from '@/services/api';
import { buildOAuthProviderOptions, normalizeProviderKey } from '@/features/authFiles/constants';
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
import styles from './AuthFilesOAuthExcludedEditPage.module.scss';
@@ -19,22 +20,6 @@ type AuthFileModelItem = { id: string; display_name?: string; type?: string; own
type LocationState = { fromAuthFiles?: boolean } | null;
const OAUTH_PROVIDER_PRESETS = [
'gemini-cli',
'vertex',
'aistudio',
'antigravity',
'claude',
'codex',
'qwen',
'kimi',
'iflow',
];
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
export function AuthFilesOAuthExcludedEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -76,16 +61,7 @@ export function AuthFilesOAuthExcludedEditPage() {
}
});
const normalizedExtras = Array.from(extraProviders)
.map((value) => value.trim())
.filter((value) => value && !OAUTH_PROVIDER_EXCLUDES.has(value.toLowerCase()));
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => value.toLowerCase()));
const extraList = normalizedExtras
.filter((value) => !baseSet.has(value.toLowerCase()))
.sort((a, b) => a.localeCompare(b));
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
return buildOAuthProviderOptions(extraProviders);
}, [excluded, files, modelAlias]);
const getTypeLabel = useCallback(
@@ -223,7 +199,7 @@ export function AuthFilesOAuthExcludedEditPage() {
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
if (status === 400 || status === 404) {
setModelsList([]);
setModelsError('unsupported');
return;
@@ -281,7 +257,7 @@ export function AuthFilesOAuthExcludedEditPage() {
try {
if (models.length) {
await authFilesApi.saveOauthExcludedModels(normalizedProvider, models);
} else {
} else if (isEditing) {
await authFilesApi.deleteOauthExcludedEntry(normalizedProvider);
}
showNotification(t('oauth_excluded.save_success'), 'success');
@@ -292,7 +268,7 @@ export function AuthFilesOAuthExcludedEditPage() {
} finally {
setSaving(false);
}
}, [handleBack, provider, selectedModels, showNotification, t]);
}, [handleBack, isEditing, provider, selectedModels, showNotification, t]);
const canSave = !disableControls && !saving && !excludedUnsupported;
@@ -352,7 +328,8 @@ export function AuthFilesOAuthExcludedEditPage() {
{providerOptions.length > 0 && (
<div className={styles.tagList}>
{providerOptions.map((option) => {
const isActive = normalizeProviderKey(provider) === option.toLowerCase();
const isActive =
normalizeProviderKey(provider) === normalizeProviderKey(option);
return (
<button
key={option}
+31 -37
View File
@@ -11,6 +11,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useAuthStore, useNotificationStore } from '@/stores';
import { authFilesApi } from '@/services/api';
import { buildOAuthProviderOptions, normalizeProviderKey } from '@/features/authFiles/constants';
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
import { generateId } from '@/utils/helpers';
import styles from './AuthFilesOAuthModelAliasEditPage.module.scss';
@@ -21,22 +22,6 @@ type LocationState = { fromAuthFiles?: boolean } | null;
type OAuthModelMappingFormEntry = OAuthModelAliasEntry & { id: string };
const OAUTH_PROVIDER_PRESETS = [
'gemini-cli',
'vertex',
'aistudio',
'antigravity',
'claude',
'codex',
'qwen',
'kimi',
'iflow',
];
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({
id: generateId(),
name: '',
@@ -76,7 +61,9 @@ export function AuthFilesOAuthModelAliasEditPage() {
const [initialLoading, setInitialLoading] = useState(true);
const [modelAliasUnsupported, setModelAliasUnsupported] = useState(false);
const [mappings, setMappings] = useState<OAuthModelMappingFormEntry[]>([buildEmptyMappingEntry()]);
const [mappings, setMappings] = useState<OAuthModelMappingFormEntry[]>([
buildEmptyMappingEntry(),
]);
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<'unsupported' | null>(null);
@@ -99,16 +86,7 @@ export function AuthFilesOAuthModelAliasEditPage() {
}
});
const normalizedExtras = Array.from(extraProviders)
.map((value) => value.trim())
.filter((value) => value && !OAUTH_PROVIDER_EXCLUDES.has(value.toLowerCase()));
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => value.toLowerCase()));
const extraList = normalizedExtras
.filter((value) => !baseSet.has(value.toLowerCase()))
.sort((a, b) => a.localeCompare(b));
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
return buildOAuthProviderOptions(extraProviders);
}, [excluded, files, modelAlias]);
const getTypeLabel = useCallback(
@@ -123,6 +101,10 @@ export function AuthFilesOAuthModelAliasEditPage() {
);
const resolvedProviderKey = useMemo(() => normalizeProviderKey(provider), [provider]);
const isEditing = useMemo(() => {
if (!resolvedProviderKey) return false;
return Object.prototype.hasOwnProperty.call(modelAlias, resolvedProviderKey);
}, [modelAlias, resolvedProviderKey]);
const title = useMemo(() => t('oauth_model_alias.add_title'), [t]);
const headerHint = useMemo(() => {
if (!provider.trim()) {
@@ -248,7 +230,7 @@ export function AuthFilesOAuthModelAliasEditPage() {
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
if (status === 400 || status === 404) {
setModelsList([]);
setModelsError('unsupported');
return;
@@ -303,30 +285,39 @@ export function AuthFilesOAuthModelAliasEditPage() {
}, []);
const handleSave = useCallback(async () => {
const channel = provider.trim();
const channel = normalizeProviderKey(provider);
if (!channel) {
showNotification(t('oauth_model_alias.provider_required'), 'error');
return;
}
const seen = new Set<string>();
const seenAlias = new Set<string>();
let hasDuplicateAlias = false;
const normalized = mappings
.map((entry) => {
const name = String(entry.name ?? '').trim();
const alias = String(entry.alias ?? '').trim();
if (!name || !alias) return null;
const key = `${name.toLowerCase()}::${alias.toLowerCase()}::${entry.fork ? '1' : '0'}`;
if (seen.has(key)) return null;
seen.add(key);
const aliasKey = alias.toLowerCase();
if (seenAlias.has(aliasKey)) {
hasDuplicateAlias = true;
return null;
}
seenAlias.add(aliasKey);
return entry.fork ? { name, alias, fork: true } : { name, alias };
})
.filter(Boolean) as OAuthModelAliasEntry[];
if (hasDuplicateAlias) {
showNotification(t('oauth_model_alias.duplicate_alias'), 'error');
return;
}
setSaving(true);
try {
if (normalized.length) {
await authFilesApi.saveOauthModelAlias(channel, normalized);
} else {
} else if (isEditing) {
await authFilesApi.deleteOauthModelAlias(channel);
}
showNotification(t('oauth_model_alias.save_success'), 'success');
@@ -337,7 +328,7 @@ export function AuthFilesOAuthModelAliasEditPage() {
} finally {
setSaving(false);
}
}, [handleBack, mappings, provider, showNotification, t]);
}, [handleBack, isEditing, mappings, provider, showNotification, t]);
const canSave = !disableControls && !saving && !modelAliasUnsupported;
@@ -378,7 +369,9 @@ export function AuthFilesOAuthModelAliasEditPage() {
<div className={styles.settingsSection}>
<div className={styles.settingsRow}>
<div className={styles.settingsInfo}>
<div className={styles.settingsLabel}>{t('oauth_model_alias.provider_label')}</div>
<div className={styles.settingsLabel}>
{t('oauth_model_alias.provider_label')}
</div>
<div className={styles.settingsDesc}>{t('oauth_model_alias.provider_hint')}</div>
</div>
<div className={styles.settingsControl}>
@@ -397,7 +390,8 @@ export function AuthFilesOAuthModelAliasEditPage() {
{providerOptions.length > 0 && (
<div className={styles.tagList}>
{providerOptions.map((option) => {
const isActive = normalizeProviderKey(provider) === option.toLowerCase();
const isActive =
normalizeProviderKey(provider) === normalizeProviderKey(option);
return (
<button
key={option}
+26 -1
View File
@@ -1,4 +1,13 @@
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
Suspense,
lazy,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import type { ReactCodeMirrorRef } from '@uiw/react-codemirror';
@@ -16,6 +25,7 @@ import {
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
import { DiffModal } from '@/components/config/DiffModal';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { useVisualConfig } from '@/hooks/useVisualConfig';
import { useNotificationStore, useAuthStore, useThemeStore, useConfigStore } from '@/stores';
import { configFileApi } from '@/services/api/configFile';
@@ -97,6 +107,21 @@ export function ConfigPage() {
const hasVisualValidationErrors =
activeTab === 'visual' &&
(Object.values(visualValidationErrors).some(Boolean) || visualHasPayloadValidationErrors);
const unsavedChangesDialog = useMemo(
() => ({
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.confirm'),
cancelText: t('common.cancel'),
}),
[t]
);
useUnsavedChangesGuard({
enabled: isCurrentLayer,
shouldBlock: isDirty,
dialog: unsavedChangesDialog,
});
const loadConfig = useCallback(async () => {
setLoading(true);
+78 -37
View File
@@ -1,14 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
IconKey,
IconBot,
IconFileText,
IconSatellite
} from '@/components/ui/icons';
import { IconKey, IconBot, IconFileText, IconSatellite } from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useModelsStore } from '@/stores';
import { apiKeysApi, providersApi, authFilesApi } from '@/services/api';
import { apiKeysApi, providersApi, authFilesApi, ampcodeApi } from '@/services/api';
import type { AmpcodeConfig } from '@/types';
import { formatDateValue } from '@/utils/format';
import styles from './DashboardPage.module.scss';
interface QuickStat {
@@ -24,11 +21,23 @@ interface ProviderStats {
gemini: number | null;
codex: number | null;
claude: number | null;
vertex: number | null;
openai: number | null;
ampcode: number | null;
}
type TimeOfDay = 'morning' | 'afternoon' | 'evening' | 'night';
const countAmpcodeConfig = (value: AmpcodeConfig | undefined): number => {
if (!value) return 0;
if (value.upstreamUrl?.trim()) return 1;
if (value.upstreamApiKey?.trim()) return 1;
if ((value.upstreamApiKeys?.length ?? 0) > 0) return 1;
if ((value.modelMappings?.length ?? 0) > 0) return 1;
if (value.forceModelMappings === true) return 1;
return 0;
};
function getTimeOfDay(): TimeOfDay {
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) return 'morning';
@@ -54,14 +63,16 @@ export function DashboardPage() {
authFiles: number | null;
}>({
apiKeys: null,
authFiles: null
authFiles: null,
});
const [providerStats, setProviderStats] = useState<ProviderStats>({
gemini: null,
codex: null,
claude: null,
openai: null
vertex: null,
openai: null,
ampcode: null,
});
const [loading, setLoading] = useState(true);
@@ -151,25 +162,38 @@ export function DashboardPage() {
const fetchStats = async () => {
setLoading(true);
try {
const [keysRes, filesRes, geminiRes, codexRes, claudeRes, openaiRes] = await Promise.allSettled([
const [
keysRes,
filesRes,
geminiRes,
codexRes,
claudeRes,
vertexRes,
openaiRes,
ampcodeRes,
] = await Promise.allSettled([
apiKeysApi.list(),
authFilesApi.list(),
providersApi.getGeminiKeys(),
providersApi.getCodexConfigs(),
providersApi.getClaudeConfigs(),
providersApi.getOpenAIProviders()
providersApi.getVertexConfigs(),
providersApi.getOpenAIProviders(),
ampcodeApi.getAmpcode(),
]);
setStats({
apiKeys: keysRes.status === 'fulfilled' ? keysRes.value.length : null,
authFiles: filesRes.status === 'fulfilled' ? filesRes.value.files.length : null
authFiles: filesRes.status === 'fulfilled' ? filesRes.value.files.length : null,
});
setProviderStats({
gemini: geminiRes.status === 'fulfilled' ? geminiRes.value.length : null,
codex: codexRes.status === 'fulfilled' ? codexRes.value.length : null,
claude: claudeRes.status === 'fulfilled' ? claudeRes.value.length : null,
openai: openaiRes.status === 'fulfilled' ? openaiRes.value.length : null
vertex: vertexRes.status === 'fulfilled' ? vertexRes.value.length : null,
openai: openaiRes.status === 'fulfilled' ? openaiRes.value.length : null,
ampcode: ampcodeRes.status === 'fulfilled' ? countAmpcodeConfig(ampcodeRes.value) : null,
});
} finally {
setLoading(false);
@@ -189,17 +213,23 @@ export function DashboardPage() {
providerStats.gemini !== null &&
providerStats.codex !== null &&
providerStats.claude !== null &&
providerStats.openai !== null;
providerStats.vertex !== null &&
providerStats.openai !== null &&
providerStats.ampcode !== null;
const hasProviderStats =
providerStats.gemini !== null ||
providerStats.codex !== null ||
providerStats.claude !== null ||
providerStats.openai !== null;
providerStats.vertex !== null ||
providerStats.openai !== null ||
providerStats.ampcode !== null;
const totalProviderKeys = providerStatsReady
? (providerStats.gemini ?? 0) +
(providerStats.codex ?? 0) +
(providerStats.claude ?? 0) +
(providerStats.openai ?? 0)
(providerStats.vertex ?? 0) +
(providerStats.openai ?? 0) +
(providerStats.ampcode ?? 0)
: 0;
const quickStats: QuickStat[] = [
@@ -209,7 +239,7 @@ export function DashboardPage() {
icon: <IconKey size={24} />,
path: '/config',
loading: loading && stats.apiKeys === null,
sublabel: t('nav.config_management')
sublabel: t('nav.config_management'),
},
{
label: t('nav.ai_providers'),
@@ -222,9 +252,11 @@ export function DashboardPage() {
gemini: providerStats.gemini ?? '-',
codex: providerStats.codex ?? '-',
claude: providerStats.claude ?? '-',
openai: providerStats.openai ?? '-'
vertex: providerStats.vertex ?? '-',
openai: providerStats.openai ?? '-',
ampcode: providerStats.ampcode ?? '-',
})
: undefined
: undefined,
},
{
label: t('nav.auth_files'),
@@ -232,7 +264,7 @@ export function DashboardPage() {
icon: <IconFileText size={24} />,
path: '/auth-files',
loading: loading && stats.authFiles === null,
sublabel: t('dashboard.oauth_credentials')
sublabel: t('dashboard.oauth_credentials'),
},
{
label: t('dashboard.available_models'),
@@ -240,8 +272,8 @@ export function DashboardPage() {
icon: <IconSatellite size={24} />,
path: '/system',
loading: modelsLoading,
sublabel: t('dashboard.available_models_desc')
}
sublabel: t('dashboard.available_models_desc'),
},
];
const routingStrategyRaw = config?.routingStrategy?.trim() || '';
@@ -268,13 +300,14 @@ export function DashboardPage() {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
day: 'numeric',
});
const formattedTime = currentTime.toLocaleTimeString(i18n.language, {
hour: '2-digit',
minute: '2-digit'
minute: '2-digit',
});
const serverBuildDateDisplay = formatDateValue(serverBuildDate, i18n.language);
return (
<div className={styles.dashboard}>
@@ -321,10 +354,8 @@ export function DashboardPage() {
)}
</span>
</div>
{serverBuildDate && (
<span className={styles.buildDate}>
{new Date(serverBuildDate).toLocaleDateString(i18n.language)}
</span>
{serverBuildDateDisplay && (
<span className={styles.buildDate}>{serverBuildDateDisplay}</span>
)}
</div>
</section>
@@ -342,9 +373,7 @@ export function DashboardPage() {
>
<div className={styles.bentoIcon}>{stat.icon}</div>
<div className={styles.bentoContent}>
<span className={styles.bentoValue}>
{stat.loading ? '...' : stat.value}
</span>
<span className={styles.bentoValue}>{stat.loading ? '...' : stat.value}</span>
<span className={styles.bentoLabel}>{stat.label}</span>
{stat.sublabel && !stat.loading && (
<span className={styles.bentoSublabel}>{stat.sublabel}</span>
@@ -362,23 +391,33 @@ export function DashboardPage() {
<div className={styles.configPillGrid}>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.debug_enable')}</span>
<span className={`${styles.configPillValue} ${config.debug ? styles.on : styles.off}`}>
<span
className={`${styles.configPillValue} ${config.debug ? styles.on : styles.off}`}
>
{config.debug ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.logging_to_file_enable')}</span>
<span className={`${styles.configPillValue} ${config.loggingToFile ? styles.on : styles.off}`}>
<span className={styles.configPillLabel}>
{t('basic_settings.logging_to_file_enable')}
</span>
<span
className={`${styles.configPillValue} ${config.loggingToFile ? styles.on : styles.off}`}
>
{config.loggingToFile ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.retry_count_label')}</span>
<span className={styles.configPillLabel}>
{t('basic_settings.retry_count_label')}
</span>
<span className={styles.configPillValue}>{config.requestRetry ?? 0}</span>
</div>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.ws_auth_enable')}</span>
<span className={`${styles.configPillValue} ${config.wsAuth ? styles.on : styles.off}`}>
<span
className={`${styles.configPillValue} ${config.wsAuth ? styles.on : styles.off}`}
>
{config.wsAuth ? t('common.yes') : t('common.no')}
</span>
</div>
@@ -390,7 +429,9 @@ export function DashboardPage() {
</div>
{config.proxyUrl && (
<div className={`${styles.configPill} ${styles.configPillWide}`}>
<span className={styles.configPillLabel}>{t('basic_settings.proxy_url_label')}</span>
<span className={styles.configPillLabel}>
{t('basic_settings.proxy_url_label')}
</span>
<span className={styles.configPillMono}>{config.proxyUrl}</span>
</div>
)}
+79 -3
View File
@@ -13,11 +13,25 @@
}
}
.pageHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
margin: 0 0 $spacing-lg 0;
@include mobile {
align-items: flex-start;
flex-direction: column;
gap: $spacing-xs;
}
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-lg 0;
margin: 0;
}
.tabBar {
@@ -84,6 +98,17 @@
}
}
.runtimeNotice {
flex: 0 0 auto;
padding: 4px 10px;
border: 1px solid var(--border-color);
border-radius: $radius-full;
color: var(--text-secondary);
background: var(--bg-secondary);
font-size: 12px;
line-height: 1.3;
}
.toolbar {
display: flex;
align-items: center;
@@ -522,8 +547,9 @@
}
.requestIdBadge {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 11px;
color: #0891b2;
background: rgba(8, 145, 178, 0.1);
@@ -622,6 +648,9 @@
@media (max-height: 820px) {
.pageTitle {
font-size: 24px;
}
.pageHeader {
margin-bottom: $spacing-md;
}
@@ -671,6 +700,9 @@
@media (max-height: 600px) {
.pageTitle {
font-size: 20px;
}
.pageHeader {
margin-bottom: $spacing-sm;
}
@@ -722,3 +754,47 @@
height: 280px;
}
}
.logCardFullscreen {
position: fixed;
inset: 0;
z-index: $z-modal - 1;
min-height: 0;
width: 100vw;
height: 100vh;
padding: $spacing-lg;
border-radius: 0;
border: none;
overflow: hidden;
background: var(--bg-primary);
@include mobile {
padding: $spacing-md;
min-height: 0;
overflow: hidden;
}
}
:global(body.logs-fullscreen-active) :global(.page-transition__layer) {
transform: none !important;
}
.logPanelFullscreen {
flex: 1 1 auto;
min-height: 0;
height: auto;
max-height: none;
resize: none;
@include tablet {
min-height: 0;
max-height: none;
}
@include mobile {
flex: 1 1 auto;
min-height: 0;
height: auto;
max-height: none;
}
}
+315 -92
View File
@@ -7,12 +7,15 @@ import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { lockScroll, unlockScroll } from '@/components/ui/scrollLock';
import {
IconChevronDown,
IconChevronUp,
IconCode,
IconDownload,
IconEyeOff,
IconMaximize2,
IconMinimize2,
IconRefreshCw,
IconSearch,
IconSlidersHorizontal,
@@ -23,17 +26,13 @@ import {
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { logsApi } from '@/services/api/logs';
import { logsApi, type LogsQuery } from '@/services/api/logs';
import { versionApi } from '@/services/api/version';
import { copyToClipboard } from '@/utils/clipboard';
import { downloadBlob } from '@/utils/download';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
import { formatUnixTimestamp } from '@/utils/format';
import {
HTTP_METHODS,
STATUS_GROUPS,
resolveStatusGroup,
type LogState,
} from './hooks/logTypes';
import { HTTP_METHODS, STATUS_GROUPS, resolveStatusGroup, type LogState } from './hooks/logTypes';
import { parseLogLine } from './hooks/logParsing';
import { useLogFilters } from './hooks/useLogFilters';
import { isNearBottom, useLogScroller } from './hooks/useLogScroller';
@@ -51,6 +50,37 @@ const MAX_BUFFER_LINES = 10000;
const LONG_PRESS_MS = 650;
const LONG_PRESS_MOVE_THRESHOLD = 10;
const getIncrementalAfter = (cursor: LogsQuery['after']): LogsQuery['after'] => {
if (typeof cursor !== 'number') return cursor;
return cursor > 1 ? cursor - 1 : undefined;
};
const findLineOverlap = (currentLines: string[], incomingLines: string[]): number => {
const maxOverlap = Math.min(currentLines.length, incomingLines.length);
for (let size = maxOverlap; size > 0; size -= 1) {
let matched = true;
for (let i = 0; i < size; i += 1) {
if (currentLines[currentLines.length - size + i] !== incomingLines[i]) {
matched = false;
break;
}
}
if (matched) return size;
}
return 0;
};
const mergeIncrementalLines = (currentLines: string[], incomingLines: string[]): string[] => {
if (currentLines.length === 0 || incomingLines.length === 0) {
return [...currentLines, ...incomingLines];
}
const overlap = findLineOverlap(currentLines, incomingLines);
return [...currentLines, ...incomingLines.slice(overlap)];
};
const getErrorMessage = (err: unknown): string => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
@@ -61,14 +91,44 @@ const getErrorMessage = (err: unknown): string => {
return typeof message === 'string' ? message : '';
};
const getErrorPayloadText = (err: unknown): string => {
if (typeof err !== 'object' || err === null) return '';
const payloads = [
(err as { data?: unknown }).data,
(err as { details?: unknown }).details,
].filter((payload) => payload !== undefined);
return payloads
.map((payload) => {
if (typeof payload === 'string') return payload;
try {
return JSON.stringify(payload);
} catch {
return '';
}
})
.join(' ');
};
const isLoggingToFileDisabledError = (err: unknown): boolean => {
const text = `${getErrorMessage(err)} ${getErrorPayloadText(err)}`.toLowerCase();
return text.includes('logging to file disabled');
};
type TabType = 'logs' | 'errors';
export function LogsPage() {
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const serverRuntimeKind = useAuthStore((state) => state.serverRuntimeKind);
const updateServerRuntimeKind = useAuthStore((state) => state.updateServerRuntimeKind);
const config = useConfigStore((state) => state.config);
const requestLogEnabled = config?.requestLog ?? false;
const loggingToFileEnabled = config?.loggingToFile ?? false;
const cpaNeedsFileLogging = serverRuntimeKind === 'cpa' && !loggingToFileEnabled;
const isHomeRuntime = serverRuntimeKind === 'home';
const [fileLoggingRequired, setFileLoggingRequired] = useState(false);
const showFileLoggingRequired = cpaNeedsFileLogging || fileLoggingRequired;
const [activeTab, setActiveTab] = useState<TabType>('logs');
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
@@ -91,8 +151,10 @@ export function LogsPage() {
const [errorLogsError, setErrorLogsError] = useState('');
const [requestLogId, setRequestLogId] = useState<string | null>(null);
const [requestLogDownloading, setRequestLogDownloading] = useState(false);
const [fullscreenLogs, setFullscreenLogs] = useState(false);
const logScrollerRef = useRef<ReturnType<typeof useLogScroller> | null>(null);
const requestLogHomeIpByIdRef = useRef<Record<string, string>>({});
const longPressRef = useRef<{
timer: number | null;
startX: number;
@@ -102,10 +164,13 @@ export function LogsPage() {
const logRequestInFlightRef = useRef(false);
const pendingFullReloadRef = useRef(false);
// 保存最新时间戳用于增量获取
const latestTimestampRef = useRef<number>(0);
// 保存最新游标用于增量获取
const latestCursorRef = useRef<LogsQuery['after']>(undefined);
const disableControls = connectionStatus !== 'connected';
const refreshDisabled = disableControls || loading || cpaNeedsFileLogging;
const autoRefreshDisabled = disableControls || showFileLoggingRequired;
const clearDisabled = disableControls || showFileLoggingRequired || isHomeRuntime;
const loadLogs = async (incremental = false) => {
if (connectionStatus !== 'connected') {
@@ -113,6 +178,18 @@ export function LogsPage() {
return;
}
if (cpaNeedsFileLogging) {
if (!incremental) {
latestCursorRef.current = undefined;
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(false);
setLogState({ buffer: [], visibleFrom: 0 });
setError('');
setLoading(false);
}
return;
}
if (logRequestInFlightRef.current) {
if (!incremental) {
pendingFullReloadRef.current = true;
@@ -135,13 +212,25 @@ export function LogsPage() {
scrollerInstance?.requestScrollToBottom();
}
const params =
incremental && latestTimestampRef.current > 0 ? { after: latestTimestampRef.current } : {};
const params: LogsQuery =
incremental && latestCursorRef.current
? { after: getIncrementalAfter(latestCursorRef.current), limit: MAX_BUFFER_LINES }
: { limit: MAX_BUFFER_LINES };
const data = await logsApi.fetchLogs(params);
setFileLoggingRequired(false);
// 更新时间戳
if (data['latest-timestamp']) {
latestTimestampRef.current = data['latest-timestamp'];
// 更新游标
if (data.latestCursor) {
latestCursorRef.current = data.latestCursor;
} else if (!incremental) {
latestCursorRef.current = undefined;
}
if (data.requestLogHomeIpById) {
requestLogHomeIpByIdRef.current = incremental
? { ...requestLogHomeIpByIdRef.current, ...data.requestLogHomeIpById }
: data.requestLogHomeIpById;
} else if (!incremental) {
requestLogHomeIpByIdRef.current = {};
}
const newLines = Array.isArray(data.lines) ? data.lines : [];
@@ -150,7 +239,7 @@ export function LogsPage() {
// 增量更新:追加新日志并限制缓冲区大小(避免内存与渲染膨胀)
setLogState((prev) => {
const prevRenderedCount = prev.buffer.length - prev.visibleFrom;
const combined = [...prev.buffer, ...newLines];
const combined = mergeIncrementalLines(prev.buffer, newLines);
const dropCount = Math.max(combined.length - MAX_BUFFER_LINES, 0);
const buffer = dropCount > 0 ? combined.slice(dropCount) : combined;
let visibleFrom = Math.max(prev.visibleFrom - dropCount, 0);
@@ -170,6 +259,16 @@ export function LogsPage() {
}
} catch (err: unknown) {
console.error('Failed to load logs:', err);
if (isLoggingToFileDisabledError(err)) {
if (!incremental) {
latestCursorRef.current = undefined;
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(true);
setLogState({ buffer: [], visibleFrom: 0 });
setError('');
}
return;
}
if (!incremental) {
setError(getErrorMessage(err) || t('logs.load_error'));
}
@@ -188,6 +287,18 @@ export function LogsPage() {
useHeaderRefresh(() => loadLogs(false));
const clearLogs = async () => {
if (isHomeRuntime) {
showNotification(t('logs.home_clear_unavailable'), 'warning');
return;
}
if (cpaNeedsFileLogging) {
showNotification(t('logs.cpa_file_logging_required'), 'warning');
return;
}
if (fileLoggingRequired) {
showNotification(t('logs.file_logging_required'), 'warning');
return;
}
showConfirmation({
title: t('logs.clear_confirm_title', { defaultValue: 'Clear Logs' }),
message: t('logs.clear_confirm'),
@@ -197,7 +308,9 @@ export function LogsPage() {
try {
await logsApi.clearLogs();
setLogState({ buffer: [], visibleFrom: 0 });
latestTimestampRef.current = 0;
latestCursorRef.current = undefined;
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(false);
showNotification(t('logs.clear_success'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
@@ -221,6 +334,12 @@ export function LogsPage() {
setLoadingErrors(false);
return;
}
if (isHomeRuntime) {
setLoadingErrors(false);
setErrorLogs([]);
setErrorLogsError('');
return;
}
setLoadingErrors(true);
setErrorLogsError('');
@@ -256,11 +375,28 @@ export function LogsPage() {
useEffect(() => {
if (connectionStatus === 'connected') {
latestTimestampRef.current = 0;
latestCursorRef.current = undefined;
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(false);
loadLogs(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectionStatus]);
}, [connectionStatus, loggingToFileEnabled]);
useEffect(() => {
if (connectionStatus !== 'connected' || serverRuntimeKind !== 'unknown') return;
let cancelled = false;
const detectRuntime = async () => {
const runtimeKind = await versionApi.detectRuntimeKind();
if (!cancelled && (runtimeKind === 'cpa' || runtimeKind === 'home')) {
updateServerRuntimeKind(runtimeKind);
}
};
void detectRuntime();
return () => {
cancelled = true;
};
}, [connectionStatus, serverRuntimeKind, updateServerRuntimeKind]);
useEffect(() => {
if (activeTab !== 'errors') return;
@@ -270,7 +406,7 @@ export function LogsPage() {
}, [activeTab, connectionStatus, requestLogEnabled]);
useEffect(() => {
if (!autoRefresh || connectionStatus !== 'connected') {
if (!autoRefresh || connectionStatus !== 'connected' || showFileLoggingRequired) {
return;
}
const id = window.setInterval(() => {
@@ -278,7 +414,7 @@ export function LogsPage() {
}, 8000);
return () => window.clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoRefresh, connectionStatus]);
}, [autoRefresh, connectionStatus, showFileLoggingRequired]);
const visibleLines = useMemo(
() => logState.buffer.slice(logState.visibleFrom),
@@ -336,14 +472,14 @@ export function LogsPage() {
return {
filteredParsedLines: filteredParsed,
filteredLines: filteredParsed.map((line) => line.raw),
removedCount: Math.max(baseLines.length - filteredParsed.length, 0)
removedCount: Math.max(baseLines.length - filteredParsed.length, 0),
};
}, [
baseLines,
filters.methodFilterSet,
filters.pathFilterSet,
filters.statusFilterSet,
parsedSearchLines
parsedSearchLines,
]);
const parsedVisibleLines = useMemo(
@@ -360,7 +496,7 @@ export function LogsPage() {
isSearching,
filteredLineCount: filteredLines.length,
hasStructuredFilters: filters.hasStructuredFilters,
showRawLogs
showRawLogs,
});
logScrollerRef.current = scroller;
@@ -423,10 +559,13 @@ export function LogsPage() {
const downloadRequestLog = async (id: string) => {
setRequestLogDownloading(true);
try {
const response = await logsApi.downloadRequestLogById(id);
const response = await logsApi.downloadRequestLogById(
id,
requestLogHomeIpByIdRef.current[id]
);
downloadBlob({
filename: `request-${id}.log`,
blob: new Blob([response.data], { type: 'text/plain' })
blob: new Blob([response.data], { type: 'text/plain' }),
});
showNotification(t('logs.request_log_download_success'), 'success');
setRequestLogId(null);
@@ -450,9 +589,33 @@ export function LogsPage() {
};
}, []);
useEffect(() => {
if (!fullscreenLogs) return;
document.body.classList.add('logs-fullscreen-active');
lockScroll();
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (document.querySelector('.modal-overlay')) return;
setFullscreenLogs(false);
};
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.classList.remove('logs-fullscreen-active');
unlockScroll();
};
}, [fullscreenLogs]);
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.runtimeNotice}>{t(`logs.runtime_${serverRuntimeKind}`)}</div>
</div>
<div className={styles.tabBar}>
<button
@@ -465,7 +628,10 @@ export function LogsPage() {
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'errors' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('errors')}
onClick={() => {
setFullscreenLogs(false);
setActiveTab('errors');
}}
>
{t('logs.error_logs_modal_title')}
</button>
@@ -473,67 +639,84 @@ export function LogsPage() {
<div className={styles.content}>
{activeTab === 'logs' && (
<Card className={styles.logCard}>
<Card
className={[styles.logCard, fullscreenLogs ? styles.logCardFullscreen : '']
.filter(Boolean)
.join(' ')}
>
{showFileLoggingRequired && (
<div className="status-badge warning">
{t(
cpaNeedsFileLogging
? 'logs.cpa_file_logging_required'
: 'logs.file_logging_required'
)}
</div>
)}
{error && <div className="error-box">{error}</div>}
<div className={styles.filters}>
<div className={styles.searchWrapper}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('logs.search_placeholder')}
className={styles.searchInput}
rightElement={
searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title="Clear"
aria-label="Clear"
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
{!fullscreenLogs && (
<>
<div className={styles.searchWrapper}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('logs.search_placeholder')}
className={styles.searchInput}
rightElement={
searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title="Clear"
aria-label="Clear"
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
<div className={styles.filterPanelHeader}>
<Button
type="button"
variant="secondary"
size="sm"
className={styles.filterPanelToggle}
onClick={() => setStructuredFiltersExpanded((prev) => !prev)}
aria-expanded={structuredFiltersExpanded}
aria-controls={structuredFiltersPanelId}
title={
structuredFiltersExpanded
? t('logs.filter_panel_collapse')
: t('logs.filter_panel_expand')
}
>
<span className={styles.filterPanelButtonContent}>
<IconSlidersHorizontal size={16} />
<span>{t('logs.filter_panel_title')}</span>
{structuredFilterCount > 0 && (
<span className={styles.filterPanelCount}>
{t('logs.filter_panel_active_count', { count: structuredFilterCount })}
<div className={styles.filterPanelHeader}>
<Button
type="button"
variant="secondary"
size="sm"
className={styles.filterPanelToggle}
onClick={() => setStructuredFiltersExpanded((prev) => !prev)}
aria-expanded={structuredFiltersExpanded}
aria-controls={structuredFiltersPanelId}
title={
structuredFiltersExpanded
? t('logs.filter_panel_collapse')
: t('logs.filter_panel_expand')
}
>
<span className={styles.filterPanelButtonContent}>
<IconSlidersHorizontal size={16} />
<span>{t('logs.filter_panel_title')}</span>
{structuredFilterCount > 0 && (
<span className={styles.filterPanelCount}>
{t('logs.filter_panel_active_count', { count: structuredFilterCount })}
</span>
)}
{structuredFiltersExpanded ? (
<IconChevronUp size={16} />
) : (
<IconChevronDown size={16} />
)}
</span>
)}
{structuredFiltersExpanded ? (
<IconChevronUp size={16} />
) : (
<IconChevronDown size={16} />
)}
</span>
</Button>
</div>
</Button>
</div>
</>
)}
{structuredFiltersExpanded && (
{!fullscreenLogs && structuredFiltersExpanded && (
<div id={structuredFiltersPanelId} className={styles.structuredFilters}>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_method')}</span>
@@ -647,7 +830,7 @@ export function LogsPage() {
variant="secondary"
size="sm"
onClick={() => loadLogs(false)}
disabled={disableControls || loading}
disabled={refreshDisabled}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
@@ -658,7 +841,7 @@ export function LogsPage() {
<ToggleSwitch
checked={autoRefresh}
onChange={(value) => setAutoRefresh(value)}
disabled={disableControls}
disabled={autoRefreshDisabled}
label={
<span className={styles.switchLabel}>
<IconTimer size={16} />
@@ -682,7 +865,7 @@ export function LogsPage() {
variant="danger"
size="sm"
onClick={clearLogs}
disabled={disableControls}
disabled={clearDisabled}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
@@ -690,6 +873,23 @@ export function LogsPage() {
{t('logs.clear_button')}
</span>
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setFullscreenLogs((prev) => !prev)}
className={styles.actionButton}
aria-pressed={fullscreenLogs}
title={
fullscreenLogs ? t('logs.exit_fullscreen_button') : t('logs.fullscreen_button')
}
>
<span className={styles.buttonContent}>
{fullscreenLogs ? <IconMinimize2 size={16} /> : <IconMaximize2 size={16} />}
{fullscreenLogs
? t('logs.exit_fullscreen_button')
: t('logs.fullscreen_button')}
</span>
</Button>
</div>
</div>
@@ -698,16 +898,16 @@ export function LogsPage() {
) : logState.buffer.length > 0 && filteredLines.length > 0 ? (
<div
ref={scroller.logViewerRef}
className={styles.logPanel}
className={[styles.logPanel, fullscreenLogs ? styles.logPanelFullscreen : '']
.filter(Boolean)
.join(' ')}
onScroll={scroller.handleLogScroll}
>
{scroller.canLoadMore && (
<div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span>
<div className={styles.loadMoreStats}>
<span>
{t('logs.loaded_lines', { count: filteredLines.length })}
</span>
<span>{t('logs.loaded_lines', { count: filteredLines.length })}</span>
{removedCount > 0 && (
<span className={styles.loadMoreCount}>
{t('logs.filtered_lines', { count: removedCount })}
@@ -828,6 +1028,19 @@ export function LogsPage() {
title={t('logs.search_empty_title')}
description={t('logs.search_empty_desc')}
/>
) : showFileLoggingRequired ? (
<EmptyState
title={t(
cpaNeedsFileLogging
? 'logs.cpa_file_logging_required_title'
: 'logs.file_logging_required_title'
)}
description={t(
cpaNeedsFileLogging
? 'logs.cpa_file_logging_required_desc'
: 'logs.file_logging_required_desc'
)}
/>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
@@ -851,9 +1064,15 @@ export function LogsPage() {
<div className="stack">
<div className="hint">{t('logs.error_logs_description')}</div>
{requestLogEnabled && (
{isHomeRuntime && (
<div className="status-badge warning">{t('logs.error_logs_home_unavailable')}</div>
)}
{requestLogEnabled && !isHomeRuntime && (
<div>
<div className="status-badge warning">{t('logs.error_logs_request_log_enabled')}</div>
<div className="status-badge warning">
{t('logs.error_logs_request_log_enabled')}
</div>
</div>
)}
@@ -901,7 +1120,11 @@ export function LogsPage() {
title={t('logs.request_log_download_title')}
footer={
<>
<Button variant="secondary" onClick={closeRequestLogModal} disabled={requestLogDownloading}>
<Button
variant="secondary"
onClick={closeRequestLogModal}
disabled={requestLogDownloading}
>
{t('common.cancel')}
</Button>
<Button
+3 -3
View File
@@ -14,6 +14,7 @@ import {
} from '@/stores';
import { configApi, versionApi } from '@/services/api';
import { apiKeysApi } from '@/services/api/apiKeys';
import { formatDateTimeValue } from '@/utils/format';
import { classifyModels } from '@/utils/models';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
@@ -109,9 +110,8 @@ export function SystemPage() {
const appVersion = __APP_VERSION__ || t('system_info.version_unknown');
const apiVersion = auth.serverVersion || t('system_info.version_unknown');
const buildTime = auth.serverBuildDate
? new Date(auth.serverBuildDate).toLocaleString(i18n.language)
: t('system_info.version_unknown');
const buildTime =
formatDateTimeValue(auth.serverBuildDate, i18n.language) || t('system_info.version_unknown');
const getIconForCategory = (categoryId: string): string | null => {
const iconEntry = MODEL_CATEGORY_ICONS[categoryId];
+29 -9
View File
@@ -10,6 +10,7 @@ const LOG_LATENCY_REGEX =
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
const LOG_NAMED_REQUEST_ID_REGEX = /\brequest[_-]?id=([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\b/i;
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
const GIN_TIMESTAMP_SEGMENT_REGEX =
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
@@ -87,6 +88,14 @@ const inferLogLevel = (line: string): LogLevel | undefined => {
return undefined;
};
const extractNamedRequestId = (text: string): string | undefined => {
const match = text.match(LOG_NAMED_REQUEST_ID_REGEX);
if (!match) return undefined;
const id = match[1];
if (/^-+$/.test(id)) return undefined;
return id;
};
const extractHttpMethodAndPath = (text: string): { method?: HttpMethod; path?: string } => {
const match = text.match(HTTP_METHOD_REGEX);
if (!match) return {};
@@ -163,16 +172,24 @@ export const parseLogLine = (raw: string): ParsedLogLine => {
}
}
// request id (8-char hex or dashes)
const requestIdIndex = segments.findIndex((segment) => LOG_REQUEST_ID_REGEX.test(segment));
// request id
const requestIdIndex = segments.findIndex(
(segment) => LOG_REQUEST_ID_REGEX.test(segment) || Boolean(extractNamedRequestId(segment))
);
if (requestIdIndex >= 0) {
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
if (match) {
const id = match[1];
if (!/^-+$/.test(id)) {
requestId = id;
}
const namedId = extractNamedRequestId(segments[requestIdIndex]);
if (namedId) {
requestId = namedId;
consumed.add(requestIdIndex);
} else {
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
if (match) {
const id = match[1];
if (!/^-+$/.test(id)) {
requestId = id;
}
consumed.add(requestIdIndex);
}
}
}
@@ -243,6 +260,10 @@ export const parseLogLine = (raw: string): ParsedLogLine => {
const parsed = extractHttpMethodAndPath(remaining);
method = parsed.method;
path = parsed.path;
if (!requestId) {
requestId = extractNamedRequestId(remaining);
}
}
if (!level) level = inferLogLevel(raw);
@@ -272,4 +293,3 @@ export const parseLogLine = (raw: string): ParsedLogLine => {
message,
};
};
+60 -48
View File
@@ -5,6 +5,7 @@
import { apiClient } from './client';
import type { AuthFilesResponse } from '@/types/authFile';
import type { OAuthModelAliasEntry } from '@/types';
import { normalizeOAuthProviderKey } from '@/utils/providerKeys';
import { parseTimestampMs } from '@/utils/timestamp';
type StatusError = { status?: number };
@@ -270,9 +271,7 @@ const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]
const result: Record<string, string[]> = {};
Object.entries(source as Record<string, unknown>).forEach(([provider, models]) => {
const key = String(provider ?? '')
.trim()
.toLowerCase();
const key = normalizeOAuthProviderKey(String(provider ?? ''));
if (!key) return;
const rawList = Array.isArray(models)
@@ -281,8 +280,8 @@ const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]
? models.split(/[\n,]+/)
: [];
const seen = new Set<string>();
const normalized: string[] = [];
const normalized = result[key] ?? [];
const seen = new Set(normalized.map((item) => item.toLowerCase()));
rawList.forEach((item) => {
const trimmed = String(item ?? '').trim();
if (!trimmed) return;
@@ -302,40 +301,36 @@ const normalizeOauthModelAlias = (payload: unknown): Record<string, OAuthModelAl
if (!payload || typeof payload !== 'object') return {};
const record = payload as Record<string, unknown>;
const source =
record['oauth-model-alias'] ??
record.items ??
payload;
const source = record['oauth-model-alias'] ?? record.items ?? payload;
if (!source || typeof source !== 'object') return {};
const result: Record<string, OAuthModelAliasEntry[]> = {};
Object.entries(source as Record<string, unknown>).forEach(([channel, mappings]) => {
const key = String(channel ?? '')
.trim()
.toLowerCase();
const key = normalizeOAuthProviderKey(String(channel ?? ''));
if (!key) return;
if (!Array.isArray(mappings)) return;
const seen = new Set<string>();
const normalized = mappings
.map((item) => {
if (!item || typeof item !== 'object') return null;
const entry = item as Record<string, unknown>;
const name = String(entry.name ?? entry.id ?? entry.model ?? '').trim();
const alias = String(entry.alias ?? '').trim();
if (!name || !alias) return null;
const fork = entry.fork === true;
return fork ? { name, alias, fork } : { name, alias };
})
const normalized = result[key] ?? [];
const seenAlias = new Set(normalized.map((entry) => entry.alias.toLowerCase()));
mappings
.map((item) => {
if (!item || typeof item !== 'object') return null;
const entry = item as Record<string, unknown>;
const name = String(entry.name ?? entry.id ?? entry.model ?? '').trim();
const alias = String(entry.alias ?? '').trim();
if (!name || !alias) return null;
const fork = entry.fork === true;
return fork ? { name, alias, fork } : { name, alias };
})
.filter(Boolean)
.filter((entry) => {
.forEach((entry) => {
const aliasEntry = entry as OAuthModelAliasEntry;
const dedupeKey = `${aliasEntry.name.toLowerCase()}::${aliasEntry.alias.toLowerCase()}::${aliasEntry.fork ? '1' : '0'}`;
if (seen.has(dedupeKey)) return false;
seen.add(dedupeKey);
return true;
}) as OAuthModelAliasEntry[];
const aliasKey = aliasEntry.alias.toLowerCase();
if (seenAlias.has(aliasKey)) return;
seenAlias.add(aliasKey);
normalized.push(aliasEntry);
});
if (normalized.length) {
result[key] = normalized;
@@ -389,9 +384,12 @@ export const authFilesApi = {
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),
downloadText: async (name: string): Promise<string> => {
const response = await apiClient.getRaw(`/auth-files/download?name=${encodeURIComponent(name)}`, {
responseType: 'blob'
});
const response = await apiClient.getRaw(
`/auth-files/download?name=${encodeURIComponent(name)}`,
{
responseType: 'blob',
}
);
const blob = response.data as Blob;
return blob.text();
},
@@ -413,10 +411,15 @@ export const authFilesApi = {
},
saveOauthExcludedModels: (provider: string, models: string[]) =>
apiClient.patch('/oauth-excluded-models', { provider, models }),
apiClient.patch('/oauth-excluded-models', {
provider: normalizeOAuthProviderKey(provider),
models,
}),
deleteOauthExcludedEntry: (provider: string) =>
apiClient.delete(`/oauth-excluded-models?provider=${encodeURIComponent(provider)}`),
apiClient.delete(
`/oauth-excluded-models?provider=${encodeURIComponent(normalizeOAuthProviderKey(provider))}`
),
replaceOauthExcludedModels: (map: Record<string, string[]>) =>
apiClient.put('/oauth-excluded-models', normalizeOauthExcludedModels(map)),
@@ -428,29 +431,36 @@ export const authFilesApi = {
},
saveOauthModelAlias: async (channel: string, aliases: OAuthModelAliasEntry[]) => {
const normalizedChannel = String(channel ?? '')
.trim()
.toLowerCase();
const normalizedAliases = normalizeOauthModelAlias({ [normalizedChannel]: aliases })[normalizedChannel] ?? [];
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, { channel: normalizedChannel, aliases: normalizedAliases });
const normalizedChannel = normalizeOAuthProviderKey(String(channel ?? ''));
const normalizedAliases =
normalizeOauthModelAlias({ [normalizedChannel]: aliases })[normalizedChannel] ?? [];
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, {
channel: normalizedChannel,
aliases: normalizedAliases,
});
},
deleteOauthModelAlias: async (channel: string) => {
const normalizedChannel = String(channel ?? '')
.trim()
.toLowerCase();
const normalizedChannel = normalizeOAuthProviderKey(String(channel ?? ''));
try {
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, { channel: normalizedChannel, aliases: [] });
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, {
channel: normalizedChannel,
aliases: [],
});
} catch (err: unknown) {
const status = getStatusCode(err);
if (status !== 405) throw err;
await apiClient.delete(`${OAUTH_MODEL_ALIAS_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
await apiClient.delete(
`${OAUTH_MODEL_ALIAS_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`
);
}
},
// 获取认证凭证支持的模型
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
async getModelsForAuthFile(
name: string
): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const data = await apiClient.get<Record<string, unknown>>(
`/auth-files/models?name=${encodeURIComponent(name)}`
);
@@ -461,8 +471,10 @@ export const authFilesApi = {
},
// 获取指定 channel 的模型定义
async getModelDefinitions(channel: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const normalizedChannel = String(channel ?? '').trim().toLowerCase();
async getModelDefinitions(
channel: string
): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const normalizedChannel = normalizeOAuthProviderKey(String(channel ?? ''));
if (!normalizedChannel) return [];
const data = await apiClient.get<Record<string, unknown>>(
`/model-definitions/${encodeURIComponent(normalizedChannel)}`
@@ -471,5 +483,5 @@ export const authFilesApi = {
return Array.isArray(models)
? (models as { id: string; display_name?: string; type?: string; owned_by?: string }[])
: [];
}
},
};
+16 -4
View File
@@ -7,10 +7,15 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import type { ApiClientConfig, ApiError } from '@/types';
import {
BUILD_DATE_HEADER_KEYS,
CPA_BUILD_DATE_HEADER_KEYS,
CPA_VERSION_HEADER_KEYS,
HOME_BUILD_DATE_HEADER_KEYS,
HOME_VERSION_HEADER_KEYS,
REQUEST_TIMEOUT_MS,
VERSION_HEADER_KEYS
} from '@/utils/constants';
import { computeApiUrl } from '@/utils/connection';
import type { ServerRuntimeKind } from '@/types';
class ApiClient {
private instance: AxiosInstance;
@@ -109,14 +114,21 @@ class ApiClient {
this.instance.interceptors.response.use(
(response) => {
const headers = response.headers as Record<string, string | undefined>;
const version = this.readHeader(headers, VERSION_HEADER_KEYS);
const buildDate = this.readHeader(headers, BUILD_DATE_HEADER_KEYS);
const homeVersion = this.readHeader(headers, HOME_VERSION_HEADER_KEYS);
const homeBuildDate = this.readHeader(headers, HOME_BUILD_DATE_HEADER_KEYS);
const cpaVersion = this.readHeader(headers, CPA_VERSION_HEADER_KEYS);
const cpaBuildDate = this.readHeader(headers, CPA_BUILD_DATE_HEADER_KEYS);
const version = homeVersion || cpaVersion || this.readHeader(headers, VERSION_HEADER_KEYS);
const buildDate =
homeBuildDate || cpaBuildDate || this.readHeader(headers, BUILD_DATE_HEADER_KEYS);
const runtimeKind: ServerRuntimeKind | null =
homeVersion || homeBuildDate ? 'home' : cpaVersion || cpaBuildDate ? 'cpa' : null;
// 触发版本更新事件(后续通过 store 处理)
if (version || buildDate) {
if (version || buildDate || runtimeKind) {
window.dispatchEvent(
new CustomEvent('server-version-update', {
detail: { version: version || null, buildDate: buildDate || null }
detail: { version: version || null, buildDate: buildDate || null, runtimeKind }
})
);
}
+131 -6
View File
@@ -5,14 +5,48 @@
import { apiClient } from './client';
import { LOGS_TIMEOUT_MS } from '@/utils/constants';
export type LogCursor = number | string;
export type LogBackendKind = 'unknown' | 'file' | 'home-db';
export interface LogsQuery {
after?: number;
after?: LogCursor;
limit?: number;
offset?: number;
}
export interface CPALogsResponse {
lines: string[];
'line-count': number;
'latest-timestamp': number;
}
export interface HomeLogRecord {
id?: number;
timestamp?: string | number;
client_ip?: string;
request_id?: string;
home_ip?: string;
level?: string;
line?: string;
created_at?: string | number;
}
export interface HomeLogsResponse {
logs?: HomeLogRecord[];
total?: number;
limit?: number;
offset?: number;
}
export interface LogsResponse {
lines: string[];
'line-count': number;
'latest-timestamp': number;
lineCount: number;
latestCursor?: LogCursor;
logBackendKind: LogBackendKind;
requestLogHomeIpById?: Record<string, string>;
total?: number;
limit?: number;
offset?: number;
}
export interface ErrorLogFile {
@@ -25,9 +59,99 @@ export interface ErrorLogsResponse {
files?: ErrorLogFile[];
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object';
const stringValue = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
const unixSecondsFromValue = (value: unknown): number => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
const text = stringValue(value);
if (!text) return 0;
const asNumber = Number(text);
if (Number.isFinite(asNumber)) return asNumber;
const asDate = Date.parse(text);
return Number.isFinite(asDate) ? Math.floor(asDate / 1000) : 0;
};
const homeCursorFromRecord = (record: HomeLogRecord): string => {
const timestamp = stringValue(record.timestamp);
if (timestamp) return timestamp;
const createdAt = stringValue(record.created_at);
return createdAt;
};
const normalizeCPALogs = (data: Record<string, unknown>): LogsResponse => {
const lines = Array.isArray(data.lines)
? data.lines.filter((line): line is string => typeof line === 'string')
: [];
const latestTimestamp = unixSecondsFromValue(data['latest-timestamp']);
const lineCount = Number(data['line-count']);
return {
lines,
lineCount: Number.isFinite(lineCount) ? lineCount : lines.length,
latestCursor: latestTimestamp > 0 ? latestTimestamp : undefined,
logBackendKind: 'file'
};
};
const normalizeHomeLogs = (data: Record<string, unknown>): LogsResponse => {
const rawLogs = Array.isArray(data.logs)
? data.logs.filter((entry): entry is HomeLogRecord => isRecord(entry))
: [];
const orderedLogs = [...rawLogs].reverse();
const lines = orderedLogs
.map((record) => record.line)
.filter((line): line is string => typeof line === 'string' && line.length > 0);
const requestLogHomeIpById = orderedLogs.reduce<Record<string, string>>((acc, record) => {
const requestId = stringValue(record.request_id);
const homeIp = stringValue(record.home_ip);
if (requestId && homeIp) {
acc[requestId] = homeIp;
}
return acc;
}, {});
const latestCursor = rawLogs.reduce<string | undefined>((latest, record) => {
const cursor = homeCursorFromRecord(record);
if (!cursor) return latest;
if (!latest) return cursor;
const latestTime = Date.parse(latest);
const cursorTime = Date.parse(cursor);
if (!Number.isFinite(latestTime) || !Number.isFinite(cursorTime)) return latest;
return cursorTime > latestTime ? cursor : latest;
}, undefined);
const total = Number(data.total);
const limit = Number(data.limit);
const offset = Number(data.offset);
return {
lines,
lineCount: Number.isFinite(total) ? total : lines.length,
latestCursor,
logBackendKind: 'home-db',
requestLogHomeIpById,
total: Number.isFinite(total) ? total : undefined,
limit: Number.isFinite(limit) ? limit : undefined,
offset: Number.isFinite(offset) ? offset : undefined
};
};
const normalizeLogsResponse = (data: unknown): LogsResponse => {
if (!isRecord(data)) {
return { lines: [], lineCount: 0, logBackendKind: 'unknown' };
}
if (Array.isArray(data.logs)) return normalizeHomeLogs(data);
if (Array.isArray(data.lines)) return normalizeCPALogs(data);
return { lines: [], lineCount: 0, logBackendKind: 'unknown' };
};
export const logsApi = {
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> =>
apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS }),
async fetchLogs(params: LogsQuery = {}): Promise<LogsResponse> {
const data = await apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS });
return normalizeLogsResponse(data);
},
clearLogs: () => apiClient.delete('/logs'),
@@ -40,8 +164,9 @@ export const logsApi = {
timeout: LOGS_TIMEOUT_MS
}),
downloadRequestLogById: (id: string) =>
downloadRequestLogById: (id: string, homeIp?: string) =>
apiClient.getRaw(`/request-log-by-id/${encodeURIComponent(id)}`, {
params: homeIp ? { home_ip: homeIp } : undefined,
responseType: 'blob',
timeout: LOGS_TIMEOUT_MS
}),
+22 -10
View File
@@ -115,7 +115,8 @@ const findRawRecord = (
usedIndexes: Set<number>,
payload: Record<string, unknown>,
index: number,
getIdentity: (record: Record<string, unknown>) => string
getIdentity: (record: Record<string, unknown>) => string,
fallbackByIndex = true
) => {
const identity = getIdentity(payload);
if (identity) {
@@ -129,10 +130,12 @@ const findRawRecord = (
}
}
const fallback = rawRecords[index];
if (fallback && !usedIndexes.has(index)) {
usedIndexes.add(index);
return fallback;
if (fallbackByIndex) {
const fallback = rawRecords[index];
if (fallback && !usedIndexes.has(index)) {
usedIndexes.add(index);
return fallback;
}
}
return undefined;
@@ -142,7 +145,8 @@ const mergeKnownRecordList = (
rawItems: unknown,
payloadItems: Record<string, unknown>[],
knownFields: readonly string[],
getIdentity: (record: Record<string, unknown>) => string
getIdentity: (record: Record<string, unknown>) => string,
fallbackByIndex = true
) => {
const rawRecords = Array.isArray(rawItems)
? rawItems.map((item) => (isRecord(item) ? item : undefined))
@@ -150,7 +154,14 @@ const mergeKnownRecordList = (
const usedIndexes = new Set<number>();
return payloadItems.map((payload, index) => {
const raw = findRawRecord(rawRecords, usedIndexes, payload, index, getIdentity);
const raw = findRawRecord(
rawRecords,
usedIndexes,
payload,
index,
getIdentity,
fallbackByIndex
);
return mergeKnownFields(raw, payload, knownFields);
});
};
@@ -167,7 +178,8 @@ const mergeModelPayloads = (raw: unknown, models: unknown) =>
isRecord(raw) ? raw.models : undefined,
models.filter(isRecord),
MODEL_ALIAS_FIELDS,
modelIdentity
modelIdentity,
false
)
: undefined;
@@ -490,6 +502,6 @@ export const providersApi = {
updateOpenAIProviderDisabled: (index: number, disabled: boolean) =>
apiClient.patch('/openai-compatibility', { index, value: { disabled } }),
deleteOpenAIProvider: (name: string) =>
apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`),
deleteOpenAIProvider: (index: number) =>
apiClient.delete(`/openai-compatibility?index=${encodeURIComponent(String(index))}`),
};
+18 -1
View File
@@ -3,7 +3,24 @@
*/
import { apiClient } from './client';
import type { ServerRuntimeKind } from '@/types';
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object';
export const versionApi = {
checkLatest: () => apiClient.get<Record<string, unknown>>('/latest-version')
checkLatest: () => apiClient.get<Record<string, unknown>>('/latest-version'),
async detectRuntimeKind(): Promise<ServerRuntimeKind> {
try {
const data = await apiClient.get('/nodes');
return isRecord(data) && Array.isArray(data.nodes) ? 'home' : 'unknown';
} catch (error: unknown) {
const status = isRecord(error) ? error.status : undefined;
if (status === 404 || status === 405) {
return 'cpa';
}
return 'unknown';
}
}
};
+50 -9
View File
@@ -5,10 +5,11 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { AuthState, LoginCredentials, ConnectionStatus } from '@/types';
import type { AuthState, LoginCredentials, ConnectionStatus, ServerRuntimeKind } from '@/types';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
import { obfuscatedStorage } from '@/services/storage/secureStorage';
import { apiClient } from '@/services/api/client';
import { versionApi } from '@/services/api/version';
import { useConfigStore } from './useConfigStore';
import { useModelsStore } from './useModelsStore';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
@@ -22,12 +23,26 @@ interface AuthStoreState extends AuthState {
logout: () => void;
checkAuth: () => Promise<boolean>;
restoreSession: () => Promise<boolean>;
updateServerVersion: (version: string | null, buildDate?: string | null) => void;
updateServerVersion: (
version: string | null,
buildDate?: string | null,
runtimeKind?: ServerRuntimeKind | null
) => void;
updateServerRuntimeKind: (runtimeKind: ServerRuntimeKind) => void;
updateConnectionStatus: (status: ConnectionStatus, error?: string | null) => void;
}
let restoreSessionPromise: Promise<boolean> | null = null;
const detectRuntimeKind = async (): Promise<ServerRuntimeKind> => {
try {
return await versionApi.detectRuntimeKind();
} catch (error) {
console.warn('Runtime kind detection failed:', error);
return 'unknown';
}
};
export const useAuthStore = create<AuthStoreState>()(
persist(
(set, get) => ({
@@ -38,6 +53,7 @@ export const useAuthStore = create<AuthStoreState>()(
rememberPassword: false,
serverVersion: null,
serverBuildDate: null,
serverRuntimeKind: 'unknown',
connectionStatus: 'disconnected',
connectionError: null,
@@ -93,7 +109,12 @@ export const useAuthStore = create<AuthStoreState>()(
const rememberPassword = credentials.rememberPassword ?? get().rememberPassword ?? false;
try {
set({ connectionStatus: 'connecting' });
set({
connectionStatus: 'connecting',
serverVersion: null,
serverBuildDate: null,
serverRuntimeKind: 'unknown'
});
useModelsStore.getState().clearCache();
// 配置 API 客户端
@@ -104,6 +125,7 @@ export const useAuthStore = create<AuthStoreState>()(
// 测试连接 - 获取配置
await useConfigStore.getState().fetchConfig(undefined, true);
const runtimeKind = await detectRuntimeKind();
// 登录成功
set({
@@ -112,7 +134,8 @@ export const useAuthStore = create<AuthStoreState>()(
managementKey,
rememberPassword,
connectionStatus: 'connected',
connectionError: null
connectionError: null,
...(runtimeKind !== 'unknown' ? { serverRuntimeKind: runtimeKind } : {})
});
if (rememberPassword) {
localStorage.setItem('isLoggedIn', 'true');
@@ -145,6 +168,7 @@ export const useAuthStore = create<AuthStoreState>()(
managementKey: '',
serverVersion: null,
serverBuildDate: null,
serverRuntimeKind: 'unknown',
connectionStatus: 'disconnected',
connectionError: null
});
@@ -165,10 +189,12 @@ export const useAuthStore = create<AuthStoreState>()(
// 验证连接
await useConfigStore.getState().fetchConfig();
const runtimeKind = await detectRuntimeKind();
set({
isAuthenticated: true,
connectionStatus: 'connected'
connectionStatus: 'connected',
...(runtimeKind !== 'unknown' ? { serverRuntimeKind: runtimeKind } : {})
});
return true;
@@ -182,8 +208,16 @@ export const useAuthStore = create<AuthStoreState>()(
},
// 更新服务器版本
updateServerVersion: (version, buildDate) => {
set({ serverVersion: version || null, serverBuildDate: buildDate || null });
updateServerVersion: (version, buildDate, runtimeKind) => {
set((state) => ({
serverVersion: version || null,
serverBuildDate: buildDate || null,
serverRuntimeKind: runtimeKind || state.serverRuntimeKind
}));
},
updateServerRuntimeKind: (runtimeKind) => {
set({ serverRuntimeKind: runtimeKind });
},
// 更新连接状态
@@ -213,7 +247,8 @@ export const useAuthStore = create<AuthStoreState>()(
...(state.rememberPassword ? { managementKey: state.managementKey } : {}),
rememberPassword: state.rememberPassword,
serverVersion: state.serverVersion,
serverBuildDate: state.serverBuildDate
serverBuildDate: state.serverBuildDate,
serverRuntimeKind: state.serverRuntimeKind
})
}
)
@@ -229,7 +264,13 @@ if (typeof window !== 'undefined') {
'server-version-update',
((e: CustomEvent) => {
const detail = e.detail || {};
useAuthStore.getState().updateServerVersion(detail.version || null, detail.buildDate || null);
const runtimeKind =
detail.runtimeKind === 'cpa' || detail.runtimeKind === 'home'
? detail.runtimeKind
: null;
useAuthStore
.getState()
.updateServerVersion(detail.version || null, detail.buildDate || null, runtimeKind);
}) as EventListener
);
}
+2
View File
@@ -18,10 +18,12 @@ export interface AuthState {
rememberPassword: boolean;
serverVersion: string | null;
serverBuildDate: string | null;
serverRuntimeKind: ServerRuntimeKind;
}
// 连接状态
export type ConnectionStatus = 'connected' | 'disconnected' | 'connecting' | 'error';
export type ServerRuntimeKind = 'unknown' | 'cpa' | 'home';
export interface ConnectionInfo {
status: ConnectionStatus;
+14 -2
View File
@@ -16,8 +16,20 @@ export const CACHE_EXPIRY_MS = 30 * 1000; // 与基线保持一致,减少管
export const DEFAULT_API_PORT = 8317;
export const MANAGEMENT_API_PREFIX = '/v0/management';
export const REQUEST_TIMEOUT_MS = 30 * 1000;
export const VERSION_HEADER_KEYS = ['x-cpa-version', 'x-server-version'];
export const BUILD_DATE_HEADER_KEYS = ['x-cpa-build-date', 'x-server-build-date'];
export const CPA_VERSION_HEADER_KEYS = ['x-cpa-version'];
export const CPA_BUILD_DATE_HEADER_KEYS = ['x-cpa-build-date'];
export const HOME_VERSION_HEADER_KEYS = ['x-cpa-home-version'];
export const HOME_BUILD_DATE_HEADER_KEYS = ['x-cpa-home-build-date'];
export const VERSION_HEADER_KEYS = [
...HOME_VERSION_HEADER_KEYS,
...CPA_VERSION_HEADER_KEYS,
'x-server-version'
];
export const BUILD_DATE_HEADER_KEYS = [
...HOME_BUILD_DATE_HEADER_KEYS,
...CPA_BUILD_DATE_HEADER_KEYS,
'x-server-build-date'
];
// 日志相关
export const LOGS_TIMEOUT_MS = 60 * 1000;
+23
View File
@@ -67,3 +67,26 @@ export function formatUnixTimestamp(value: unknown, locale?: string): string {
if (Number.isNaN(date.getTime())) return '';
return locale ? date.toLocaleString(locale) : date.toLocaleString();
}
export function parseDateValue(value: unknown): Date | null {
if (value === null || value === undefined || value === '') return null;
const date =
typeof value === 'number'
? new Date(value < 1e12 ? value * 1000 : value)
: (parseTimestamp(value) ?? new Date(String(value)));
return Number.isNaN(date.getTime()) ? null : date;
}
export function formatDateValue(value: unknown, locale?: string): string {
const date = parseDateValue(value);
if (!date) return '';
return locale ? date.toLocaleDateString(locale) : date.toLocaleDateString();
}
export function formatDateTimeValue(value: unknown, locale?: string): string {
const date = parseDateValue(value);
if (!date) return '';
return locale ? date.toLocaleString(locale) : date.toLocaleString();
}
+12
View File
@@ -0,0 +1,12 @@
const OAUTH_PROVIDER_ALIASES: Record<string, string> = {
'anti-gravity': 'antigravity',
gemini: 'gemini-cli',
grok: 'xai',
'x-ai': 'xai',
'x.ai': 'xai',
};
export const normalizeOAuthProviderKey = (value: string): string => {
const key = value.trim().toLowerCase().replace(/_/g, '-');
return OAUTH_PROVIDER_ALIASES[key] ?? key;
};