mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
21 Commits
@@ -213,7 +213,6 @@ export function MainLayout() {
|
||||
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
@@ -431,16 +430,12 @@ export function MainLayout() {
|
||||
metaKey: 'nav_meta.quota_management',
|
||||
icon: sidebarIcons.quota,
|
||||
},
|
||||
...(config?.loggingToFile
|
||||
? [
|
||||
{
|
||||
path: '/logs',
|
||||
labelKey: 'nav.logs',
|
||||
metaKey: 'nav_meta.logs',
|
||||
icon: sidebarIcons.logs,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
path: '/logs',
|
||||
labelKey: 'nav.logs',
|
||||
metaKey: 'nav_meta.logs',
|
||||
icon: sidebarIcons.logs,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -24,8 +24,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
@@ -47,13 +47,13 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
padding: 0 11px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color $transition-fast, border-color $transition-fast;
|
||||
|
||||
@@ -93,12 +93,12 @@
|
||||
}
|
||||
|
||||
.filterToolbarBtn {
|
||||
padding: 2px 8px;
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
@@ -115,7 +115,7 @@
|
||||
.filterEmpty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
@@ -141,6 +141,6 @@
|
||||
|
||||
.filterItemLabel {
|
||||
font-family: $font-mono;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
.eyebrow {
|
||||
margin: 4px 8px 8px;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
@@ -68,8 +68,8 @@
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
@@ -90,7 +90,7 @@
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -98,7 +98,7 @@
|
||||
}
|
||||
|
||||
.itemSubtitle {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 2px;
|
||||
font-weight: 400;
|
||||
@@ -113,10 +113,10 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
padding: 2px 8px;
|
||||
min-width: 26px;
|
||||
padding: 2px 9px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -55,10 +55,10 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color $transition-fast, border-color $transition-fast,
|
||||
@@ -111,12 +111,12 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--muted-bg);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ProviderHeaderCard({
|
||||
}
|
||||
>
|
||||
<span className={`${styles.btnIcon} ${isFetching ? styles.spin : ''}`.trim()}>
|
||||
{isFetching ? <IconLoader2 size={14} /> : <IconRefreshCw size={14} />}
|
||||
{isFetching ? <IconLoader2 size={16} /> : <IconRefreshCw size={16} />}
|
||||
</span>
|
||||
<span>
|
||||
{isFetching
|
||||
@@ -66,7 +66,7 @@ export function ProviderHeaderCard({
|
||||
onClick={onNew}
|
||||
disabled={isNewDisabled}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
<IconPlus size={16} />
|
||||
<span>{newLabel ?? t('providersPage.actions.new')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
@@ -66,7 +66,7 @@
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
@@ -84,13 +84,13 @@
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 12px 0 36px;
|
||||
height: 38px;
|
||||
padding: 0 12px 0 38px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
@@ -107,7 +107,7 @@
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 12px;
|
||||
left: 13px;
|
||||
transform: translateY(-50%);
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
@@ -119,7 +119,7 @@
|
||||
background: var(--amber-10);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
color: var(--amber-text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -135,7 +135,7 @@
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.emptyAction {
|
||||
|
||||
@@ -93,7 +93,7 @@ export function ProviderResourcePanel({
|
||||
{group.id !== 'ampcode' ? (
|
||||
<div className={styles.searchWrap}>
|
||||
<span className={styles.searchIcon} aria-hidden="true">
|
||||
<IconSearch size={14} />
|
||||
<IconSearch size={16} />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
@@ -141,15 +141,15 @@ export function ProviderResourcePanel({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 12px',
|
||||
padding: '7px 13px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--border-color)',
|
||||
background: 'var(--bg-primary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
<IconPlus size={16} />
|
||||
<span>{t('providersPage.actions.new')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
|
||||
.primarySub {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
font-family: $font-mono;
|
||||
overflow: hidden;
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
.baseUrl {
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -40,12 +40,12 @@
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--muted-bg);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.metricsCell {
|
||||
@@ -59,11 +59,11 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--muted-bg);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -81,12 +81,12 @@
|
||||
.flagTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--primary-30);
|
||||
background: var(--primary-10);
|
||||
color: var(--primary-color);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
@@ -96,10 +96,10 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -122,9 +122,9 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
border: 1px solid transparent;
|
||||
@@ -173,8 +173,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
@@ -136,7 +136,7 @@ export function ProviderResourceTable({
|
||||
if (r.brand === 'ampcode' && r.flags.isPlaceholder) {
|
||||
return (
|
||||
<span className={`${styles.statusBadge} ${styles.statusDisabled}`}>
|
||||
<IconAlertTriangle size={12} />
|
||||
<IconAlertTriangle size={14} />
|
||||
{t('providersPage.status.notConfigured')}
|
||||
</span>
|
||||
);
|
||||
@@ -144,14 +144,14 @@ export function ProviderResourceTable({
|
||||
if (r.disabled) {
|
||||
return (
|
||||
<span className={`${styles.statusBadge} ${styles.statusDisabled}`}>
|
||||
<IconAlertTriangle size={12} />
|
||||
<IconAlertTriangle size={14} />
|
||||
{t('providersPage.status.disabled')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={`${styles.statusBadge} ${styles.statusActive}`}>
|
||||
<IconCheckCircle2 size={12} />
|
||||
<IconCheckCircle2 size={14} />
|
||||
{t('providersPage.status.active')}
|
||||
</span>
|
||||
);
|
||||
@@ -297,7 +297,7 @@ export function ProviderResourceTable({
|
||||
onView(resource);
|
||||
}}
|
||||
>
|
||||
<IconEye size={14} />
|
||||
<IconEye size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -310,7 +310,7 @@ export function ProviderResourceTable({
|
||||
onEdit(resource);
|
||||
}}
|
||||
>
|
||||
<IconPencil size={14} />
|
||||
<IconPencil size={16} />
|
||||
</button>
|
||||
{isAmpcode ? (
|
||||
<button
|
||||
@@ -324,7 +324,7 @@ export function ProviderResourceTable({
|
||||
onDelete(resource);
|
||||
}}
|
||||
>
|
||||
<IconTrash2 size={14} />
|
||||
<IconTrash2 size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -338,7 +338,7 @@ export function ProviderResourceTable({
|
||||
onDelete(resource);
|
||||
}}
|
||||
>
|
||||
<IconTrash2 size={14} />
|
||||
<IconTrash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
@@ -131,10 +131,10 @@
|
||||
.statusRate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
padding: 1px 6px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -2,11 +2,7 @@ import { useEffect, useId, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Collapsible } from '@/components/ui/Collapsible';
|
||||
import { IconPlus, IconX } from '@/components/ui/icons';
|
||||
import type {
|
||||
AmpcodeConfig,
|
||||
AmpcodeModelMapping,
|
||||
AmpcodeUpstreamApiKeyMapping,
|
||||
} from '@/types';
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping } from '@/types';
|
||||
import type { ProviderResource } from '../../types';
|
||||
import styles from './sharedForm.module.scss';
|
||||
|
||||
@@ -34,7 +30,7 @@ function buildState(config?: AmpcodeConfig | null): AmpcodeFormState {
|
||||
: [emptyModelMapping()];
|
||||
return {
|
||||
upstreamUrl: safe.upstreamUrl ?? '',
|
||||
upstreamApiKey: safe.upstreamApiKey ?? '',
|
||||
upstreamApiKey: '',
|
||||
forceModelMappings: safe.forceModelMappings === true,
|
||||
upstreamMappings,
|
||||
modelMappings,
|
||||
@@ -66,9 +62,7 @@ export function AmpcodeForm({
|
||||
const fid = useId();
|
||||
const initialConfig = (resource?.raw as AmpcodeConfig | undefined) ?? {};
|
||||
const [form, setForm] = useState<AmpcodeFormState>(() => buildState(initialConfig));
|
||||
const [initialFormSignature] = useState<string>(() =>
|
||||
JSON.stringify(buildState(initialConfig))
|
||||
);
|
||||
const [initialFormSignature] = useState<string>(() => JSON.stringify(buildState(initialConfig)));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isDirty = useMemo(
|
||||
@@ -109,7 +103,8 @@ export function AmpcodeForm({
|
||||
|
||||
const next: AmpcodeConfig = {
|
||||
upstreamUrl: form.upstreamUrl.trim() || undefined,
|
||||
upstreamApiKey: form.upstreamApiKey.trim() || undefined,
|
||||
upstreamApiKey:
|
||||
form.upstreamApiKey.trim() || initialConfig.upstreamApiKey?.trim() || undefined,
|
||||
upstreamApiKeys: upstreamApiKeys.length ? upstreamApiKeys : undefined,
|
||||
modelMappings: modelMappings.length ? modelMappings : undefined,
|
||||
forceModelMappings: form.forceModelMappings,
|
||||
@@ -149,9 +144,11 @@ export function AmpcodeForm({
|
||||
className={styles.input}
|
||||
type="password"
|
||||
value={form.upstreamApiKey}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({ ...s, upstreamApiKey: e.target.value }))
|
||||
}
|
||||
onChange={(e) => setForm((s) => ({ ...s, upstreamApiKey: e.target.value }))}
|
||||
autoComplete="new-password"
|
||||
data-1p-ignore="true"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
disabled={mutating}
|
||||
/>
|
||||
</div>
|
||||
@@ -161,9 +158,7 @@ export function AmpcodeForm({
|
||||
className={styles.checkboxBox}
|
||||
checked={form.forceModelMappings}
|
||||
disabled={mutating}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({ ...s, forceModelMappings: e.target.checked }))
|
||||
}
|
||||
onChange={(e) => setForm((s) => ({ ...s, forceModelMappings: e.target.checked }))}
|
||||
/>
|
||||
<span className={styles.checkboxText}>
|
||||
<span>{t('providersPage.ampcode.forceModelMappings')}</span>
|
||||
@@ -193,9 +188,7 @@ export function AmpcodeForm({
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>
|
||||
{t('providersPage.ampcode.upstreamApiKey')}
|
||||
</label>
|
||||
<label className={styles.label}>{t('providersPage.ampcode.upstreamApiKey')}</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={m.upstreamApiKey}
|
||||
@@ -255,10 +248,7 @@ export function AmpcodeForm({
|
||||
<Collapsible label={t('providersPage.ampcode.modelMappingsSection')}>
|
||||
<div className={styles.entriesList}>
|
||||
{form.modelMappings.map((m, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 8 }}
|
||||
>
|
||||
<div key={idx} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 8 }}>
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder="from"
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
IconAlertTriangle,
|
||||
IconCheckCircle2,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconLoader2,
|
||||
IconPlus,
|
||||
IconX,
|
||||
@@ -11,11 +13,7 @@ import {
|
||||
import { Collapsible } from '@/components/ui/Collapsible';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { hasDisableAllModelsRule } from '@/components/providers/utils';
|
||||
import type {
|
||||
GeminiKeyConfig,
|
||||
OpenAIProviderConfig,
|
||||
ProviderKeyConfig,
|
||||
} from '@/types';
|
||||
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { PROVIDER_DESCRIPTORS } from '../../descriptors';
|
||||
import type {
|
||||
@@ -53,15 +51,8 @@ const emptyModel = (): ModelEntryInput => ({ name: '', alias: '' });
|
||||
const emptyApiKeyEntry = (): ApiKeyEntryInput => ({
|
||||
apiKey: '',
|
||||
proxyUrl: '',
|
||||
headersText: '',
|
||||
});
|
||||
|
||||
const headersObjectToText = (headers?: Record<string, string>): string =>
|
||||
Object.entries(headers ?? {})
|
||||
.filter(([k]) => k.trim())
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n');
|
||||
|
||||
const stripDisableAllRule = (list?: string[]): string[] =>
|
||||
(list ?? []).filter((s) => s.trim() !== '*');
|
||||
|
||||
@@ -84,13 +75,9 @@ function buildInitialForm(
|
||||
excludedModelsText: '',
|
||||
websockets: brand === 'codex' ? false : undefined,
|
||||
cloak:
|
||||
brand === 'claude'
|
||||
? { mode: '', strictMode: false, sensitiveWordsText: '' }
|
||||
: undefined,
|
||||
testModel:
|
||||
brand === 'openaiCompatibility' || brand === 'claude' ? '' : undefined,
|
||||
apiKeyEntries:
|
||||
brand === 'openaiCompatibility' ? [emptyApiKeyEntry()] : undefined,
|
||||
brand === 'claude' ? { mode: '', strictMode: false, sensitiveWordsText: '' } : undefined,
|
||||
testModel: brand === 'openaiCompatibility' || brand === 'claude' ? '' : undefined,
|
||||
apiKeyEntries: brand === 'openaiCompatibility' ? [emptyApiKeyEntry()] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,9 +107,9 @@ function buildInitialForm(
|
||||
testModel: cfg.testModel ?? '',
|
||||
apiKeyEntries: cfg.apiKeyEntries?.length
|
||||
? cfg.apiKeyEntries.map((entry) => ({
|
||||
apiKey: entry.apiKey,
|
||||
apiKey: '',
|
||||
existingApiKey: entry.apiKey,
|
||||
proxyUrl: entry.proxyUrl ?? '',
|
||||
headersText: headersObjectToText(entry.headers),
|
||||
authIndex: entry.authIndex,
|
||||
}))
|
||||
: [emptyApiKeyEntry()],
|
||||
@@ -133,6 +120,10 @@ function buildInitialForm(
|
||||
const disabled = hasDisableAllModelsRule(cfg.excludedModels);
|
||||
const excludedList = stripDisableAllRule(cfg.excludedModels);
|
||||
return {
|
||||
// Keep the API key blank in edit mode. Pre-filling the real key makes this
|
||||
// password field a browser-autofill target (the saved management key can
|
||||
// overwrite it) and defeats the "leave empty = keep unchanged" contract; an
|
||||
// empty field is preserved on save via buildProviderKeyConfig's existing fallback.
|
||||
apiKey: '',
|
||||
name: '',
|
||||
baseUrl: cfg.baseUrl ?? '',
|
||||
@@ -152,15 +143,13 @@ function buildInitialForm(
|
||||
? Object.entries(cfg.headers).map(([k, v]) => ({ key: k, value: String(v) }))
|
||||
: [emptyHeader()],
|
||||
excludedModelsText: excludedList.join('\n'),
|
||||
websockets:
|
||||
brand === 'codex' ? (cfg as ProviderKeyConfig).websockets === true : undefined,
|
||||
websockets: brand === 'codex' ? (cfg as ProviderKeyConfig).websockets === true : undefined,
|
||||
cloak:
|
||||
brand === 'claude'
|
||||
? {
|
||||
mode: (cfg as ProviderKeyConfig).cloak?.mode ?? '',
|
||||
strictMode: (cfg as ProviderKeyConfig).cloak?.strictMode === true,
|
||||
sensitiveWordsText:
|
||||
(cfg as ProviderKeyConfig).cloak?.sensitiveWords?.join('\n') ?? '',
|
||||
sensitiveWordsText: (cfg as ProviderKeyConfig).cloak?.sensitiveWords?.join('\n') ?? '',
|
||||
}
|
||||
: undefined,
|
||||
testModel: brand === 'claude' ? '' : undefined,
|
||||
@@ -211,6 +200,20 @@ export function BaseProviderForm({
|
||||
JSON.stringify(buildInitialForm(brand, resource, mode))
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPasswords, setShowPasswords] = useState<Set<number>>(new Set());
|
||||
const [showSingleApiKey, setShowSingleApiKey] = useState(false);
|
||||
|
||||
const togglePasswordVisibility = (idx: number) => {
|
||||
setShowPasswords((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) {
|
||||
next.delete(idx);
|
||||
} else {
|
||||
next.add(idx);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => JSON.stringify(form) !== initialFormSignature,
|
||||
@@ -229,9 +232,7 @@ export function BaseProviderForm({
|
||||
|
||||
const fallbackAuthIndex = useMemo(() => {
|
||||
if (mode !== 'edit' || !resource) return '';
|
||||
return (
|
||||
(resource.raw as { authIndex?: string } | undefined)?.authIndex ?? ''
|
||||
);
|
||||
return (resource.raw as { authIndex?: string } | undefined)?.authIndex ?? '';
|
||||
}, [mode, resource]);
|
||||
|
||||
const connectivityMessages = useMemo<ConnectivityErrorMessages>(
|
||||
@@ -240,8 +241,7 @@ export function BaseProviderForm({
|
||||
endpointInvalid: t('providersPage.connectivity.endpointInvalid'),
|
||||
apiKeyRequired: t('providersPage.connectivity.apiKeyRequired'),
|
||||
modelRequired: t('providersPage.connectivity.modelRequired'),
|
||||
timeout: (seconds: number) =>
|
||||
t('providersPage.connectivity.timeout', { seconds }),
|
||||
timeout: (seconds: number) => t('providersPage.connectivity.timeout', { seconds }),
|
||||
requestFailed: t('providersPage.connectivity.requestFailed'),
|
||||
}),
|
||||
[t]
|
||||
@@ -295,9 +295,7 @@ export function BaseProviderForm({
|
||||
const autoLabel = firstName
|
||||
? t('providersPage.form.testModelAutoWith', { name: firstName })
|
||||
: t('providersPage.form.testModelAutoEmpty');
|
||||
const opts: Array<{ value: string; label: string }> = [
|
||||
{ value: '', label: autoLabel },
|
||||
];
|
||||
const opts: Array<{ value: string; label: string }> = [{ value: '', label: autoLabel }];
|
||||
names.forEach((n) => opts.push({ value: n, label: n }));
|
||||
const tm = (form.testModel ?? '').trim();
|
||||
if (tm && !seen.has(tm)) {
|
||||
@@ -377,11 +375,7 @@ export function BaseProviderForm({
|
||||
if (descriptor.supportsName && !form.name.trim()) {
|
||||
return t('providersPage.form.validation.nameRequired');
|
||||
}
|
||||
if (
|
||||
descriptor.supportsApiKey &&
|
||||
mode === 'create' &&
|
||||
!form.apiKey.trim()
|
||||
) {
|
||||
if (descriptor.supportsApiKey && mode === 'create' && !form.apiKey.trim()) {
|
||||
return t('providersPage.form.validation.apiKeyRequired');
|
||||
}
|
||||
if (descriptor.baseUrlRequired && !form.baseUrl.trim()) {
|
||||
@@ -424,12 +418,29 @@ export function BaseProviderForm({
|
||||
);
|
||||
const apiKeyEntries = useMemo(
|
||||
() =>
|
||||
form.apiKeyEntries && form.apiKeyEntries.length
|
||||
? form.apiKeyEntries
|
||||
: [emptyApiKeyEntry()],
|
||||
form.apiKeyEntries && form.apiKeyEntries.length ? form.apiKeyEntries : [emptyApiKeyEntry()],
|
||||
[form.apiKeyEntries]
|
||||
);
|
||||
|
||||
const removeApiKeyEntry = (removeIdx: number) => {
|
||||
setShowPasswords((prev) => {
|
||||
if (!prev.size) return prev;
|
||||
const next = new Set<number>();
|
||||
prev.forEach((idx) => {
|
||||
if (idx < removeIdx) {
|
||||
next.add(idx);
|
||||
} else if (idx > removeIdx) {
|
||||
next.add(idx - 1);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
updateField(
|
||||
'apiKeyEntries',
|
||||
apiKeyEntries.filter((_, i) => i !== removeIdx)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form id={formId} className={styles.form} onSubmit={handleSubmit} noValidate>
|
||||
{/* 基础字段 */}
|
||||
@@ -454,19 +465,43 @@ export function BaseProviderForm({
|
||||
<label className={styles.label} htmlFor={`${fid}-apiKey`}>
|
||||
{t('providersPage.form.apiKey')}
|
||||
</label>
|
||||
<input
|
||||
id={`${fid}-apiKey`}
|
||||
className={styles.input}
|
||||
type="password"
|
||||
value={form.apiKey}
|
||||
onChange={(e) => updateField('apiKey', e.target.value)}
|
||||
placeholder={
|
||||
mode === 'edit'
|
||||
? t('providersPage.form.apiKeyEditPlaceholder')
|
||||
: t('providersPage.form.apiKeyCreatePlaceholder')
|
||||
}
|
||||
disabled={mutating}
|
||||
/>
|
||||
<div className={styles.passwordField}>
|
||||
<input
|
||||
id={`${fid}-apiKey`}
|
||||
className={styles.passwordInput}
|
||||
type={showSingleApiKey ? 'text' : 'password'}
|
||||
value={form.apiKey}
|
||||
onChange={(e) => updateField('apiKey', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
data-1p-ignore="true"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
placeholder={
|
||||
mode === 'edit'
|
||||
? t('providersPage.form.apiKeyEditPlaceholder')
|
||||
: t('providersPage.form.apiKeyCreatePlaceholder')
|
||||
}
|
||||
disabled={mutating}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.passwordToggle}
|
||||
onClick={() => setShowSingleApiKey((v) => !v)}
|
||||
disabled={mutating}
|
||||
aria-label={
|
||||
showSingleApiKey
|
||||
? t('providersPage.form.hideApiKey')
|
||||
: t('providersPage.form.showApiKey')
|
||||
}
|
||||
title={
|
||||
showSingleApiKey
|
||||
? t('providersPage.form.hideApiKey')
|
||||
: t('providersPage.form.showApiKey')
|
||||
}
|
||||
>
|
||||
{showSingleApiKey ? <IconEyeOff size={16} /> : <IconEye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -588,9 +623,7 @@ export function BaseProviderForm({
|
||||
</div>
|
||||
) : null}
|
||||
{brand === 'claude' && connectivity.claudeStatus.state === 'error' ? (
|
||||
<div className={styles.connectivityError}>
|
||||
{connectivity.claudeStatus.message}
|
||||
</div>
|
||||
<div className={styles.connectivityError}>{connectivity.claudeStatus.message}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -631,11 +664,24 @@ export function BaseProviderForm({
|
||||
{descriptor.supportsApiKeyEntries && form.apiKeyEntries ? (
|
||||
<Collapsible
|
||||
label={t('providersPage.form.apiKeyEntriesSection')}
|
||||
hint={`${apiKeyEntries.filter((e) => e.apiKey.trim()).length}`}
|
||||
hint={`${
|
||||
apiKeyEntries.filter((e) => e.apiKey.trim() || e.existingApiKey?.trim()).length
|
||||
}`}
|
||||
defaultOpen
|
||||
>
|
||||
<div className={styles.entriesList}>
|
||||
<div className={styles.entriesToolbar}>
|
||||
<div className={`${styles.entriesToolbar} ${styles.entriesToolbarSplit}`}>
|
||||
{/* Add entry button on the left */}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addBtn}
|
||||
disabled={mutating}
|
||||
onClick={() => updateField('apiKeyEntries', [...apiKeyEntries, emptyApiKeyEntry()])}
|
||||
>
|
||||
<IconPlus size={12} />
|
||||
<span>{t('providersPage.form.addApiKeyEntry')}</span>
|
||||
</button>
|
||||
{/* Test all button on the right */}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.connectivityBtn}
|
||||
@@ -650,24 +696,23 @@ export function BaseProviderForm({
|
||||
<span>{t('providersPage.connectivity.testAll')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{apiKeyEntries.map((entry, idx) => {
|
||||
const status = connectivity.openaiStatuses[idx] ?? {
|
||||
{[...apiKeyEntries].reverse().map((entry, visualIdx) => {
|
||||
const realIdx = apiKeyEntries.length - 1 - visualIdx;
|
||||
const status = connectivity.openaiStatuses[realIdx] ?? {
|
||||
state: 'idle' as ConnectivityState,
|
||||
message: '',
|
||||
};
|
||||
return (
|
||||
<div key={idx} className={styles.entryCard}>
|
||||
<div key={realIdx} className={styles.entryCard}>
|
||||
<div className={styles.entryCardHeader}>
|
||||
<span>
|
||||
{t('providersPage.form.apiKeyEntry', { index: idx + 1 })}
|
||||
</span>
|
||||
<span>{t('providersPage.form.apiKeyEntry', { index: realIdx + 1 })}</span>
|
||||
<div className={styles.entryCardHeaderRight}>
|
||||
<ConnectivityStatusIcon state={status.state} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.connectivityBtnGhost}
|
||||
disabled={mutating || status.state === 'loading'}
|
||||
onClick={() => void connectivity.runOpenAIKey(idx)}
|
||||
onClick={() => void connectivity.runOpenAIKey(realIdx)}
|
||||
>
|
||||
{status.state === 'loading' ? (
|
||||
<span className={`${styles.statusIcon} ${styles.statusIconLoading}`}>
|
||||
@@ -680,41 +725,64 @@ export function BaseProviderForm({
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
disabled={mutating || apiKeyEntries.length <= 1}
|
||||
onClick={() =>
|
||||
updateField(
|
||||
'apiKeyEntries',
|
||||
apiKeyEntries.filter((_, i) => i !== idx)
|
||||
)
|
||||
}
|
||||
onClick={() => removeApiKeyEntry(realIdx)}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>
|
||||
{t('providersPage.form.apiKey')}
|
||||
</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="password"
|
||||
value={entry.apiKey}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'apiKeyEntries',
|
||||
apiKeyEntries.map((it, i) =>
|
||||
i === idx ? { ...it, apiKey: e.target.value } : it
|
||||
<label className={styles.label}>{t('providersPage.form.apiKey')}</label>
|
||||
<div className={styles.passwordField}>
|
||||
<input
|
||||
className={styles.passwordInput}
|
||||
type={showPasswords.has(realIdx) ? 'text' : 'password'}
|
||||
value={entry.apiKey}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'apiKeyEntries',
|
||||
apiKeyEntries.map((it, i) =>
|
||||
i === realIdx ? { ...it, apiKey: e.target.value } : it
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={mutating}
|
||||
placeholder={t('providersPage.form.apiKeyCreatePlaceholder')}
|
||||
/>
|
||||
}
|
||||
autoComplete="new-password"
|
||||
data-1p-ignore="true"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
disabled={mutating}
|
||||
placeholder={
|
||||
entry.existingApiKey
|
||||
? t('providersPage.form.apiKeyEditPlaceholder')
|
||||
: t('providersPage.form.apiKeyCreatePlaceholder')
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.passwordToggle}
|
||||
onClick={() => togglePasswordVisibility(realIdx)}
|
||||
disabled={mutating}
|
||||
aria-label={
|
||||
showPasswords.has(realIdx)
|
||||
? t('providersPage.form.hideApiKey')
|
||||
: t('providersPage.form.showApiKey')
|
||||
}
|
||||
title={
|
||||
showPasswords.has(realIdx)
|
||||
? t('providersPage.form.hideApiKey')
|
||||
: t('providersPage.form.showApiKey')
|
||||
}
|
||||
>
|
||||
{showPasswords.has(realIdx) ? (
|
||||
<IconEyeOff size={16} />
|
||||
) : (
|
||||
<IconEye size={16} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>
|
||||
{t('providersPage.form.proxyUrl')}
|
||||
</label>
|
||||
<label className={styles.label}>{t('providersPage.form.proxyUrl')}</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={entry.proxyUrl}
|
||||
@@ -722,7 +790,7 @@ export function BaseProviderForm({
|
||||
updateField(
|
||||
'apiKeyEntries',
|
||||
apiKeyEntries.map((it, i) =>
|
||||
i === idx ? { ...it, proxyUrl: e.target.value } : it
|
||||
i === realIdx ? { ...it, proxyUrl: e.target.value } : it
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -730,49 +798,12 @@ export function BaseProviderForm({
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>
|
||||
{t('providersPage.form.headers')}
|
||||
<span className={styles.labelHint}>
|
||||
{' '}
|
||||
· {t('providersPage.form.headersHint')}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={entry.headersText}
|
||||
rows={3}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'apiKeyEntries',
|
||||
apiKeyEntries.map((it, i) =>
|
||||
i === idx ? { ...it, headersText: e.target.value } : it
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={mutating}
|
||||
placeholder="X-Custom-Header: value"
|
||||
/>
|
||||
</div>
|
||||
{status.state === 'error' ? (
|
||||
<div className={styles.connectivityError}>
|
||||
{status.message}
|
||||
</div>
|
||||
<div className={styles.connectivityError}>{status.message}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addBtn}
|
||||
disabled={mutating}
|
||||
onClick={() =>
|
||||
updateField('apiKeyEntries', [...apiKeyEntries, emptyApiKeyEntry()])
|
||||
}
|
||||
>
|
||||
<IconPlus size={12} />
|
||||
<span>{t('providersPage.form.addApiKeyEntry')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
@@ -792,9 +823,7 @@ export function BaseProviderForm({
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'headers',
|
||||
headersList.map((it, i) =>
|
||||
i === idx ? { ...it, key: e.target.value } : it
|
||||
)
|
||||
headersList.map((it, i) => (i === idx ? { ...it, key: e.target.value } : it))
|
||||
)
|
||||
}
|
||||
disabled={mutating}
|
||||
@@ -884,9 +913,7 @@ export function BaseProviderForm({
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'models',
|
||||
modelsList.map((it, i) =>
|
||||
i === idx ? { ...it, name: e.target.value } : it
|
||||
)
|
||||
modelsList.map((it, i) => (i === idx ? { ...it, name: e.target.value } : it))
|
||||
)
|
||||
}
|
||||
disabled={mutating}
|
||||
@@ -898,9 +925,7 @@ export function BaseProviderForm({
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'models',
|
||||
modelsList.map((it, i) =>
|
||||
i === idx ? { ...it, alias: e.target.value } : it
|
||||
)
|
||||
modelsList.map((it, i) => (i === idx ? { ...it, alias: e.target.value } : it))
|
||||
)
|
||||
}
|
||||
disabled={mutating}
|
||||
@@ -936,9 +961,7 @@ export function BaseProviderForm({
|
||||
{descriptor.supportsExcludedModels ? (
|
||||
<Collapsible label={t('providersPage.form.excludedSection')}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.labelHint}>
|
||||
{t('providersPage.form.excludedHint')}
|
||||
</span>
|
||||
<span className={styles.labelHint}>{t('providersPage.form.excludedHint')}</span>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
rows={4}
|
||||
@@ -955,9 +978,7 @@ export function BaseProviderForm({
|
||||
<Collapsible label={t('providersPage.form.cloakSection')}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>
|
||||
{t('providersPage.form.cloakMode')}
|
||||
</label>
|
||||
<label className={styles.label}>{t('providersPage.form.cloakMode')}</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={form.cloak.mode}
|
||||
@@ -979,16 +1000,12 @@ export function BaseProviderForm({
|
||||
</span>
|
||||
</label>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>
|
||||
{t('providersPage.form.cloakSensitiveWords')}
|
||||
</label>
|
||||
<label className={styles.label}>{t('providersPage.form.cloakSensitiveWords')}</label>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
rows={3}
|
||||
value={form.cloak.sensitiveWordsText}
|
||||
onChange={(e) =>
|
||||
updateCloak('sensitiveWordsText', e.target.value)
|
||||
}
|
||||
onChange={(e) => updateCloak('sensitiveWordsText', e.target.value)}
|
||||
disabled={mutating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -221,6 +221,10 @@
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.entriesToolbarSplit {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.connectivityRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -490,6 +494,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
.passwordField {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.passwordInput {
|
||||
@extend .input;
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.passwordToggle {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.entriesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -5,11 +5,7 @@ import {
|
||||
buildOpenAIChatCompletionsEndpoint,
|
||||
} from '@/components/providers/utils';
|
||||
import { buildHeaderObject, hasHeader } from '@/utils/headers';
|
||||
import type {
|
||||
ApiKeyEntryInput,
|
||||
ModelEntryInput,
|
||||
ProviderBrand,
|
||||
} from '../../types';
|
||||
import type { ApiKeyEntryInput, ModelEntryInput, ProviderBrand } from '../../types';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
|
||||
@@ -29,10 +25,7 @@ const errorMessage = (err: unknown): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const pickModel = (
|
||||
testModel: string | undefined,
|
||||
models: ModelEntryInput[]
|
||||
): string => {
|
||||
const pickModel = (testModel: string | undefined, models: ModelEntryInput[]): string => {
|
||||
const trimmed = (testModel ?? '').trim();
|
||||
if (trimmed) return trimmed;
|
||||
for (const m of models) {
|
||||
@@ -42,27 +35,8 @@ const pickModel = (
|
||||
return '';
|
||||
};
|
||||
|
||||
const parseHeadersText = (text: string): Record<string, string> => {
|
||||
const out: Record<string, string> = {};
|
||||
String(text ?? '')
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const sep = line.indexOf(':');
|
||||
if (sep <= 0) return;
|
||||
const key = line.slice(0, sep).trim();
|
||||
const value = line.slice(sep + 1).trim();
|
||||
if (!key) return;
|
||||
out[key] = value;
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const resolveBearerToken = (headers: Record<string, string>): string => {
|
||||
const auth = Object.entries(headers).find(
|
||||
([k]) => k.toLowerCase() === 'authorization'
|
||||
)?.[1];
|
||||
const auth = Object.entries(headers).find(([k]) => k.toLowerCase() === 'authorization')?.[1];
|
||||
if (!auth) return '';
|
||||
const match = String(auth).match(/^Bearer\s+(.+)$/i);
|
||||
return match ? match[1].trim() : '';
|
||||
@@ -116,22 +90,21 @@ export function useConnectivityTest(
|
||||
|
||||
const entriesCount = apiKeyEntries?.length ?? 0;
|
||||
|
||||
const [openaiStatuses, setOpenaiStatuses] = useState<ConnectivityStatus[]>(
|
||||
() => Array.from({ length: entriesCount }, () => IDLE)
|
||||
const [openaiStatuses, setOpenaiStatuses] = useState<ConnectivityStatus[]>(() =>
|
||||
Array.from({ length: entriesCount }, () => IDLE)
|
||||
);
|
||||
const [claudeStatus, setClaudeStatus] = useState<ConnectivityStatus>(IDLE);
|
||||
const [inFlight, setInFlight] = useState(0);
|
||||
|
||||
const entrySignatures = useMemo(
|
||||
() =>
|
||||
(apiKeyEntries ?? []).map(
|
||||
(entry) =>
|
||||
[
|
||||
entry.apiKey ?? '',
|
||||
entry.authIndex ?? '',
|
||||
entry.proxyUrl ?? '',
|
||||
entry.headersText ?? '',
|
||||
].join('||')
|
||||
(apiKeyEntries ?? []).map((entry) =>
|
||||
[
|
||||
entry.apiKey ?? '',
|
||||
entry.existingApiKey ?? '',
|
||||
entry.authIndex ?? '',
|
||||
entry.proxyUrl ?? '',
|
||||
].join('||')
|
||||
),
|
||||
[apiKeyEntries]
|
||||
);
|
||||
@@ -171,16 +144,13 @@ export function useConnectivityTest(
|
||||
setClaudeStatus(IDLE);
|
||||
}, [signature]);
|
||||
|
||||
const updateOpenaiStatus = useCallback(
|
||||
(idx: number, value: ConnectivityStatus) => {
|
||||
setOpenaiStatuses((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = value;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
const updateOpenaiStatus = useCallback((idx: number, value: ConnectivityStatus) => {
|
||||
setOpenaiStatuses((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = value;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const runOpenAIKey = useCallback(
|
||||
async (idx: number): Promise<boolean> => {
|
||||
@@ -203,7 +173,7 @@ export function useConnectivityTest(
|
||||
return false;
|
||||
}
|
||||
const entry = apiKeyEntries?.[idx];
|
||||
const entryKey = (entry?.apiKey ?? '').trim();
|
||||
const entryKey = (entry?.apiKey ?? '').trim() || (entry?.existingApiKey ?? '').trim();
|
||||
const resolvedAuthIndex =
|
||||
(entry?.authIndex ?? '').trim() || (authIndex ?? '').trim() || undefined;
|
||||
if (!entryKey && !resolvedAuthIndex) {
|
||||
@@ -225,7 +195,6 @@ export function useConnectivityTest(
|
||||
const headerObj: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...buildHeaderObject(formHeaders),
|
||||
...parseHeadersText(entry?.headersText ?? ''),
|
||||
};
|
||||
if (!hasHeader(headerObj, 'authorization')) {
|
||||
if (entryKey) {
|
||||
@@ -375,17 +344,7 @@ export function useConnectivityTest(
|
||||
} finally {
|
||||
setInFlight((n) => n - 1);
|
||||
}
|
||||
}, [
|
||||
apiKey,
|
||||
authIndex,
|
||||
baseUrl,
|
||||
brand,
|
||||
fallbackApiKey,
|
||||
formHeaders,
|
||||
messages,
|
||||
models,
|
||||
testModel,
|
||||
]);
|
||||
}, [apiKey, authIndex, baseUrl, brand, fallbackApiKey, formHeaders, messages, models, testModel]);
|
||||
|
||||
return {
|
||||
openaiStatuses,
|
||||
|
||||
@@ -14,23 +14,6 @@ export const MODEL_DISCOVERY_BRANDS: ReadonlyArray<ProviderBrand> = [
|
||||
export const isModelDiscoveryBrand = (brand: ProviderBrand): boolean =>
|
||||
MODEL_DISCOVERY_BRANDS.includes(brand);
|
||||
|
||||
const parseHeadersText = (text: string): Record<string, string> => {
|
||||
const out: Record<string, string> = {};
|
||||
String(text ?? '')
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const sep = line.indexOf(':');
|
||||
if (sep <= 0) return;
|
||||
const key = line.slice(0, sep).trim();
|
||||
const value = line.slice(sep + 1).trim();
|
||||
if (!key) return;
|
||||
out[key] = value;
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const toErrorMessage = (err: unknown): string => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
@@ -57,18 +40,8 @@ export interface UseModelDiscoveryResult {
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useModelDiscovery(
|
||||
args: UseModelDiscoveryArgs
|
||||
): UseModelDiscoveryResult {
|
||||
const {
|
||||
brand,
|
||||
baseUrl,
|
||||
formHeaders,
|
||||
apiKeyEntries,
|
||||
apiKey,
|
||||
fallbackApiKey,
|
||||
authIndex,
|
||||
} = args;
|
||||
export function useModelDiscovery(args: UseModelDiscoveryArgs): UseModelDiscoveryResult {
|
||||
const { brand, baseUrl, formHeaders, apiKeyEntries, apiKey, fallbackApiKey, authIndex } = args;
|
||||
|
||||
const available = isModelDiscoveryBrand(brand);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -109,19 +82,18 @@ export function useModelDiscovery(
|
||||
resolvedAuthIndex
|
||||
);
|
||||
} else if (brand === 'openaiCompatibility') {
|
||||
const firstEntry = (apiKeyEntries ?? []).find((e) =>
|
||||
(e.apiKey ?? '').trim() || (e.authIndex ?? '').trim()
|
||||
const firstEntry = (apiKeyEntries ?? []).find(
|
||||
(e) =>
|
||||
(e.apiKey ?? '').trim() || (e.existingApiKey ?? '').trim() || (e.authIndex ?? '').trim()
|
||||
);
|
||||
const entryKey = (firstEntry?.apiKey ?? '').trim();
|
||||
const entryAuthIndex =
|
||||
(firstEntry?.authIndex ?? '').trim() || resolvedAuthIndex;
|
||||
const entryHeaders = parseHeadersText(firstEntry?.headersText ?? '');
|
||||
const headers = { ...baseHeaders, ...entryHeaders };
|
||||
const entryKey =
|
||||
(firstEntry?.apiKey ?? '').trim() || (firstEntry?.existingApiKey ?? '').trim();
|
||||
const entryAuthIndex = (firstEntry?.authIndex ?? '').trim() || resolvedAuthIndex;
|
||||
try {
|
||||
next = await modelsApi.fetchModelsViaApiCall(
|
||||
baseUrl,
|
||||
entryKey,
|
||||
headers,
|
||||
baseHeaders,
|
||||
entryAuthIndex
|
||||
);
|
||||
} catch (firstErr) {
|
||||
@@ -144,16 +116,7 @@ export function useModelDiscovery(
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
available,
|
||||
apiKey,
|
||||
apiKeyEntries,
|
||||
authIndex,
|
||||
baseUrl,
|
||||
brand,
|
||||
fallbackApiKey,
|
||||
formHeaders,
|
||||
]);
|
||||
}, [available, apiKey, apiKeyEntries, authIndex, baseUrl, brand, fallbackApiKey, formHeaders]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setModels([]);
|
||||
@@ -163,11 +126,9 @@ export function useModelDiscovery(
|
||||
}, []);
|
||||
|
||||
const inputSignature = useMemo(() => {
|
||||
const headerSig = formHeaders
|
||||
.map((h) => `${h.key}:${h.value}`)
|
||||
.join('|');
|
||||
const headerSig = formHeaders.map((h) => `${h.key}:${h.value}`).join('|');
|
||||
const entriesSig = (apiKeyEntries ?? [])
|
||||
.map((e) => `${e.apiKey ?? ''}::${e.authIndex ?? ''}::${e.headersText ?? ''}`)
|
||||
.map((e) => `${e.apiKey ?? ''}::${e.existingApiKey ?? ''}::${e.authIndex ?? ''}`)
|
||||
.join('|');
|
||||
return [
|
||||
baseUrl,
|
||||
|
||||
@@ -90,8 +90,8 @@ export interface ModelEntryInput {
|
||||
|
||||
export interface ApiKeyEntryInput {
|
||||
apiKey: string;
|
||||
existingApiKey?: string;
|
||||
proxyUrl: string;
|
||||
headersText: string;
|
||||
authIndex?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ampcodeApi,
|
||||
providersApi,
|
||||
} from '@/services/api';
|
||||
import { ampcodeApi, providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore } from '@/stores';
|
||||
import {
|
||||
withDisableAllModelsRule,
|
||||
@@ -22,10 +19,7 @@ import {
|
||||
openaiToResource,
|
||||
vertexToResource,
|
||||
} from './adapters';
|
||||
import {
|
||||
PROVIDER_BRAND_ORDER,
|
||||
PROVIDER_PATHS,
|
||||
} from './descriptors';
|
||||
import { PROVIDER_BRAND_ORDER, PROVIDER_PATHS } from './descriptors';
|
||||
import type {
|
||||
ProviderBrand,
|
||||
ProviderEntryFormInput,
|
||||
@@ -49,14 +43,8 @@ export interface UseProviderWorkbenchResult {
|
||||
snapshot: ProviderSnapshot | null;
|
||||
refetch: () => Promise<void>;
|
||||
|
||||
createProvider: (
|
||||
brand: ProviderBrand,
|
||||
input: ProviderEntryFormInput
|
||||
) => Promise<void>;
|
||||
updateProvider: (
|
||||
resource: ProviderResource,
|
||||
input: ProviderEntryFormInput
|
||||
) => Promise<void>;
|
||||
createProvider: (brand: ProviderBrand, input: ProviderEntryFormInput) => Promise<void>;
|
||||
updateProvider: (resource: ProviderResource, input: ProviderEntryFormInput) => Promise<void>;
|
||||
deleteProvider: (resource: ProviderResource) => Promise<void>;
|
||||
toggleDisabled: (resource: ProviderResource, disabled: boolean) => Promise<void>;
|
||||
saveAmpcode: (config: AmpcodeConfig) => Promise<void>;
|
||||
@@ -74,23 +62,6 @@ const parseTextList = (text: string): string[] =>
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const parseHeadersText = (text: string): Record<string, string> => {
|
||||
const result: Record<string, string> = {};
|
||||
text
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const sep = line.indexOf(':');
|
||||
if (sep <= 0) return;
|
||||
const key = line.slice(0, sep).trim();
|
||||
const value = line.slice(sep + 1).trim();
|
||||
if (!key) return;
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const headersFromEntries = (
|
||||
entries: Array<{ key: string; value: string }>
|
||||
): Record<string, string> => {
|
||||
@@ -174,14 +145,15 @@ const buildOpenAIConfig = (
|
||||
.filter((m) => m.name);
|
||||
const apiKeyEntries =
|
||||
input.apiKeyEntries
|
||||
?.map((entry) => ({
|
||||
apiKey: entry.apiKey.trim(),
|
||||
proxyUrl: entry.proxyUrl.trim() || undefined,
|
||||
headers: Object.keys(parseHeadersText(entry.headersText)).length
|
||||
? parseHeadersText(entry.headersText)
|
||||
: undefined,
|
||||
authIndex: entry.authIndex?.trim() || undefined,
|
||||
}))
|
||||
?.map((entry, index) => {
|
||||
const fallbackApiKey =
|
||||
entry.existingApiKey?.trim() || existing?.apiKeyEntries?.[index]?.apiKey?.trim() || '';
|
||||
return {
|
||||
apiKey: entry.apiKey.trim() || fallbackApiKey,
|
||||
proxyUrl: entry.proxyUrl.trim() || undefined,
|
||||
authIndex: entry.authIndex?.trim() || undefined,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.apiKey) ?? [];
|
||||
|
||||
return {
|
||||
@@ -189,9 +161,7 @@ const buildOpenAIConfig = (
|
||||
name: input.name.trim(),
|
||||
baseUrl: input.baseUrl.trim(),
|
||||
prefix: input.prefix.trim() || undefined,
|
||||
apiKeyEntries: apiKeyEntries.length
|
||||
? apiKeyEntries
|
||||
: existing?.apiKeyEntries ?? [],
|
||||
apiKeyEntries: apiKeyEntries.length ? apiKeyEntries : (existing?.apiKeyEntries ?? []),
|
||||
disabled: input.disabled,
|
||||
headers: Object.keys(headers).length ? headers : undefined,
|
||||
models: models.length ? models : undefined,
|
||||
|
||||
@@ -730,12 +730,16 @@
|
||||
},
|
||||
"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",
|
||||
"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 +752,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",
|
||||
@@ -1313,6 +1324,8 @@
|
||||
"apiKeyEntriesSection": "API key entries",
|
||||
"apiKeyEntry": "Key #{{index}}",
|
||||
"addApiKeyEntry": "Add key entry",
|
||||
"showApiKey": "Show API key",
|
||||
"hideApiKey": "Hide API key",
|
||||
"validation": {
|
||||
"nameRequired": "Name is required",
|
||||
"apiKeyRequired": "At least one API key is required",
|
||||
|
||||
@@ -727,12 +727,16 @@
|
||||
},
|
||||
"logs": {
|
||||
"title": "Просмотр журналов",
|
||||
"runtime_unknown": "Текущий режим: определяется",
|
||||
"runtime_cpa": "Текущий режим: CPA",
|
||||
"runtime_home": "Текущий режим: Home",
|
||||
"refresh_button": "Обновить журналы",
|
||||
"clear_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 +749,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": "Не удалось загрузить журналы",
|
||||
@@ -1310,6 +1321,8 @@
|
||||
"apiKeyEntriesSection": "API-ключи",
|
||||
"apiKeyEntry": "Ключ #{{index}}",
|
||||
"addApiKeyEntry": "Добавить ключ",
|
||||
"showApiKey": "Показать ключ API",
|
||||
"hideApiKey": "Скрыть ключ API",
|
||||
"validation": {
|
||||
"nameRequired": "Название обязательно",
|
||||
"apiKeyRequired": "Нужен хотя бы один API-ключ",
|
||||
|
||||
@@ -730,12 +730,16 @@
|
||||
},
|
||||
"logs": {
|
||||
"title": "日志查看",
|
||||
"runtime_unknown": "当前运行端:识别中",
|
||||
"runtime_cpa": "当前运行端:CPA",
|
||||
"runtime_home": "当前运行端:Home",
|
||||
"refresh_button": "刷新日志",
|
||||
"clear_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 +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": "当前日志接口需要先开启“日志记录到文件”后才能读取文件日志。",
|
||||
"file_logging_required_title": "需要开启文件日志",
|
||||
"file_logging_required_desc": "当前日志接口读取本地日志文件。请在配置中开启 logging-to-file 后再刷新查看。",
|
||||
"home_clear_unavailable": "Home 日志来自数据库查询,当前不支持从这里清空。",
|
||||
"log_content": "日志内容",
|
||||
"loading": "正在加载日志...",
|
||||
"load_error": "加载日志失败",
|
||||
@@ -1313,6 +1324,8 @@
|
||||
"apiKeyEntriesSection": "API 密钥条目",
|
||||
"apiKeyEntry": "密钥 #{{index}}",
|
||||
"addApiKeyEntry": "添加密钥条目",
|
||||
"showApiKey": "显示密钥",
|
||||
"hideApiKey": "隐藏密钥",
|
||||
"validation": {
|
||||
"nameRequired": "名称必填",
|
||||
"apiKeyRequired": "至少填写一个 API 密钥",
|
||||
|
||||
@@ -756,12 +756,16 @@
|
||||
},
|
||||
"logs": {
|
||||
"title": "記錄檢視",
|
||||
"runtime_unknown": "目前執行端:識別中",
|
||||
"runtime_cpa": "目前執行端:CPA",
|
||||
"runtime_home": "目前執行端:Home",
|
||||
"refresh_button": "重新整理記錄",
|
||||
"clear_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 +778,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": "載入記錄失敗",
|
||||
@@ -1339,6 +1350,8 @@
|
||||
"apiKeyEntriesSection": "API 金鑰條目",
|
||||
"apiKeyEntry": "金鑰 #{{index}}",
|
||||
"addApiKeyEntry": "新增金鑰條目",
|
||||
"showApiKey": "顯示金鑰",
|
||||
"hideApiKey": "隱藏金鑰",
|
||||
"validation": {
|
||||
"nameRequired": "名稱必填",
|
||||
"apiKeyRequired": "至少填寫一個 API 金鑰",
|
||||
|
||||
@@ -272,6 +272,8 @@ export function LoginPage() {
|
||||
label={t('login.management_key_label')}
|
||||
placeholder={t('login.management_key_placeholder')}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
name="cpa-management-key"
|
||||
autoComplete="current-password"
|
||||
value={managementKey}
|
||||
onChange={(e) => setManagementKey(e.target.value)}
|
||||
onKeyDown={handleSubmitKeyDown}
|
||||
|
||||
@@ -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;
|
||||
@@ -622,6 +647,9 @@
|
||||
@media (max-height: 820px) {
|
||||
.pageTitle {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
@@ -671,6 +699,9 @@
|
||||
@media (max-height: 600px) {
|
||||
.pageTitle {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
|
||||
+159
-19
@@ -23,7 +23,8 @@ 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';
|
||||
@@ -61,14 +62,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 });
|
||||
@@ -93,6 +124,7 @@ export function LogsPage() {
|
||||
const [requestLogDownloading, setRequestLogDownloading] = 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 +134,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 +148,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 +182,25 @@ export function LogsPage() {
|
||||
scrollerInstance?.requestScrollToBottom();
|
||||
}
|
||||
|
||||
const params =
|
||||
incremental && latestTimestampRef.current > 0 ? { after: latestTimestampRef.current } : {};
|
||||
const params: LogsQuery =
|
||||
incremental && latestCursorRef.current
|
||||
? { after: 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 : [];
|
||||
@@ -170,6 +229,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 +257,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 +278,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 +304,12 @@ export function LogsPage() {
|
||||
setLoadingErrors(false);
|
||||
return;
|
||||
}
|
||||
if (isHomeRuntime) {
|
||||
setLoadingErrors(false);
|
||||
setErrorLogs([]);
|
||||
setErrorLogsError('');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingErrors(true);
|
||||
setErrorLogsError('');
|
||||
@@ -256,11 +345,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 +376,7 @@ export function LogsPage() {
|
||||
}, [activeTab, connectionStatus, requestLogEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || connectionStatus !== 'connected') {
|
||||
if (!autoRefresh || connectionStatus !== 'connected' || showFileLoggingRequired) {
|
||||
return;
|
||||
}
|
||||
const id = window.setInterval(() => {
|
||||
@@ -278,7 +384,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),
|
||||
@@ -423,7 +529,10 @@ 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' })
|
||||
@@ -452,7 +561,12 @@ export function LogsPage() {
|
||||
|
||||
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
|
||||
@@ -474,6 +588,15 @@ export function LogsPage() {
|
||||
<div className={styles.content}>
|
||||
{activeTab === 'logs' && (
|
||||
<Card className={styles.logCard}>
|
||||
{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}>
|
||||
@@ -647,7 +770,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 +781,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 +805,7 @@ export function LogsPage() {
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={clearLogs}
|
||||
disabled={disableControls}
|
||||
disabled={clearDisabled}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<span className={styles.buttonContent}>
|
||||
@@ -828,6 +951,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,7 +987,11 @@ 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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -83,8 +83,8 @@ export const apiCallApi = {
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiCallResult> => {
|
||||
const response = await apiClient.post<Record<string, unknown>>('/api-call', payload, config);
|
||||
const statusCode = Number(response?.status_code ?? response?.statusCode ?? 0);
|
||||
const header = (response?.header ?? response?.headers ?? {}) as Record<string, string[]>;
|
||||
const statusCode = Number(response?.status_code ?? 0);
|
||||
const header = (response?.header ?? {}) as Record<string, string[]>;
|
||||
const { bodyText, body } = normalizeBody(response?.body);
|
||||
|
||||
return {
|
||||
|
||||
@@ -91,51 +91,19 @@ const normalizeBatchFailures = (value: unknown): AuthFileBatchFailure[] => {
|
||||
}, []);
|
||||
};
|
||||
|
||||
const deriveSuccessfulFileNames = (requestedNames: string[], failed: AuthFileBatchFailure[]): string[] => {
|
||||
const failedNames = new Set(
|
||||
failed
|
||||
.map((entry) => entry.name.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
if (failedNames.size === 0) {
|
||||
return [...requestedNames];
|
||||
}
|
||||
|
||||
return requestedNames.filter((name) => !failedNames.has(name));
|
||||
};
|
||||
|
||||
const normalizeBatchUploadResponse = (
|
||||
payload: AuthFileBatchUploadResponse | undefined,
|
||||
requestedNames: string[]
|
||||
): AuthFileBatchUploadResult => {
|
||||
const failed = normalizeBatchFailures(payload?.failed);
|
||||
const uploadedFilesFromPayload = normalizeBatchFileNames(payload?.files);
|
||||
const uploaded =
|
||||
typeof payload?.uploaded === 'number'
|
||||
? payload.uploaded
|
||||
: uploadedFilesFromPayload.length > 0
|
||||
? uploadedFilesFromPayload.length
|
||||
: requestedNames.length === 1 && failed.length === 0
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
let uploadedFiles = uploadedFilesFromPayload;
|
||||
if (uploadedFiles.length === 0 && uploaded > 0) {
|
||||
if (failed.length === 0 && uploaded === requestedNames.length) {
|
||||
uploadedFiles = [...requestedNames];
|
||||
} else {
|
||||
const derivedNames = deriveSuccessfulFileNames(requestedNames, failed);
|
||||
if (derivedNames.length === uploaded) {
|
||||
uploadedFiles = derivedNames;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filesFromPayload = normalizeBatchFileNames(payload?.files);
|
||||
// Backend single-file success path returns only {status:"ok"} (auth_files.go:680).
|
||||
// Derive count + names from the request when no failures and counts are absent.
|
||||
const inferFromRequest = payload?.uploaded === undefined && failed.length === 0;
|
||||
return {
|
||||
status: typeof payload?.status === 'string' ? payload.status : failed.length > 0 ? 'partial' : 'ok',
|
||||
uploaded,
|
||||
files: uploadedFiles,
|
||||
status: payload?.status ?? (failed.length > 0 ? 'partial' : 'ok'),
|
||||
uploaded: payload?.uploaded ?? (inferFromRequest ? requestedNames.length : 0),
|
||||
files: filesFromPayload.length ? filesFromPayload : inferFromRequest ? [...requestedNames] : [],
|
||||
failed,
|
||||
};
|
||||
};
|
||||
@@ -145,32 +113,13 @@ const normalizeBatchDeleteResponse = (
|
||||
requestedNames: string[]
|
||||
): AuthFileBatchDeleteResult => {
|
||||
const failed = normalizeBatchFailures(payload?.failed);
|
||||
const deletedFilesFromPayload = normalizeBatchFileNames(payload?.files);
|
||||
const deleted =
|
||||
typeof payload?.deleted === 'number'
|
||||
? payload.deleted
|
||||
: deletedFilesFromPayload.length > 0
|
||||
? deletedFilesFromPayload.length
|
||||
: requestedNames.length === 1 && failed.length === 0
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
let deletedFiles = deletedFilesFromPayload;
|
||||
if (deletedFiles.length === 0 && deleted > 0) {
|
||||
if (failed.length === 0 && deleted === requestedNames.length) {
|
||||
deletedFiles = [...requestedNames];
|
||||
} else {
|
||||
const derivedNames = deriveSuccessfulFileNames(requestedNames, failed);
|
||||
if (derivedNames.length === deleted) {
|
||||
deletedFiles = derivedNames;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filesFromPayload = normalizeBatchFileNames(payload?.files);
|
||||
// Backend single-name delete returns only {status:"ok"} (auth_files.go:794).
|
||||
const inferFromRequest = payload?.deleted === undefined && failed.length === 0;
|
||||
return {
|
||||
status: typeof payload?.status === 'string' ? payload.status : failed.length > 0 ? 'partial' : 'ok',
|
||||
deleted,
|
||||
files: deletedFiles,
|
||||
status: payload?.status ?? (failed.length > 0 ? 'partial' : 'ok'),
|
||||
deleted: payload?.deleted ?? (inferFromRequest ? requestedNames.length : 0),
|
||||
files: filesFromPayload.length ? filesFromPayload : inferFromRequest ? [...requestedNames] : [],
|
||||
failed,
|
||||
};
|
||||
};
|
||||
@@ -181,7 +130,7 @@ const readTextField = (entry: AuthFileEntry, key: string): string => {
|
||||
};
|
||||
|
||||
const readDateField = (entry: AuthFileEntry): number => {
|
||||
const candidates = [entry['modtime'], entry.modified, entry['updated_at'], entry['last_refresh']];
|
||||
const candidates = [entry['modtime'], entry['updated_at'], entry['last_refresh']];
|
||||
|
||||
for (const value of candidates) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -204,12 +153,7 @@ const readDateField = (entry: AuthFileEntry): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const isRuntimeOnlyEntry = (entry: AuthFileEntry): boolean => {
|
||||
const value = entry['runtime_only'] ?? entry.runtimeOnly;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') return value.trim().toLowerCase() === 'true';
|
||||
return false;
|
||||
};
|
||||
const isRuntimeOnlyEntry = (entry: AuthFileEntry): boolean => entry['runtime_only'] === true;
|
||||
|
||||
const hasMeaningfulValue = (value: unknown): boolean => {
|
||||
if (value == null) return false;
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export const configApi = {
|
||||
*/
|
||||
async getLogsMaxTotalSizeMb(): Promise<number> {
|
||||
const data = await apiClient.get<Record<string, unknown>>('/logs-max-total-size-mb');
|
||||
const value = data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0;
|
||||
const value = data?.['logs-max-total-size-mb'] ?? 0;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
},
|
||||
@@ -88,7 +88,7 @@ export const configApi = {
|
||||
*/
|
||||
async getForceModelPrefix(): Promise<boolean> {
|
||||
const data = await apiClient.get<Record<string, unknown>>('/force-model-prefix');
|
||||
return Boolean(data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false);
|
||||
return Boolean(data?.['force-model-prefix'] ?? false);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -101,7 +101,7 @@ export const configApi = {
|
||||
*/
|
||||
async getRoutingStrategy(): Promise<string> {
|
||||
const data = await apiClient.get<Record<string, unknown>>('/routing/strategy');
|
||||
const strategy = data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy;
|
||||
const strategy = data?.strategy;
|
||||
return typeof strategy === 'string' ? strategy : 'round-robin';
|
||||
},
|
||||
|
||||
|
||||
+131
-6
@@ -5,14 +5,48 @@
|
||||
import { apiClient } from './client';
|
||||
import { LOGS_TIMEOUT_MS } from '@/utils/constants';
|
||||
|
||||
export type LogCursor = number | string;
|
||||
export type LogBackendKind = 'unknown' | 'file' | 'home-db';
|
||||
|
||||
export interface LogsQuery {
|
||||
after?: number;
|
||||
after?: LogCursor;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CPALogsResponse {
|
||||
lines: string[];
|
||||
'line-count': number;
|
||||
'latest-timestamp': number;
|
||||
}
|
||||
|
||||
export interface HomeLogRecord {
|
||||
id?: number;
|
||||
timestamp?: string | number;
|
||||
client_ip?: string;
|
||||
request_id?: string;
|
||||
home_ip?: string;
|
||||
level?: string;
|
||||
line?: string;
|
||||
created_at?: string | number;
|
||||
}
|
||||
|
||||
export interface HomeLogsResponse {
|
||||
logs?: HomeLogRecord[];
|
||||
total?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface LogsResponse {
|
||||
lines: string[];
|
||||
'line-count': number;
|
||||
'latest-timestamp': number;
|
||||
lineCount: number;
|
||||
latestCursor?: LogCursor;
|
||||
logBackendKind: LogBackendKind;
|
||||
requestLogHomeIpById?: Record<string, string>;
|
||||
total?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ErrorLogFile {
|
||||
@@ -25,9 +59,99 @@ export interface ErrorLogsResponse {
|
||||
files?: ErrorLogFile[];
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object';
|
||||
|
||||
const stringValue = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
const unixSecondsFromValue = (value: unknown): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
const text = stringValue(value);
|
||||
if (!text) return 0;
|
||||
const asNumber = Number(text);
|
||||
if (Number.isFinite(asNumber)) return asNumber;
|
||||
const asDate = Date.parse(text);
|
||||
return Number.isFinite(asDate) ? Math.floor(asDate / 1000) : 0;
|
||||
};
|
||||
|
||||
const homeCursorFromRecord = (record: HomeLogRecord): string => {
|
||||
const timestamp = stringValue(record.timestamp);
|
||||
if (timestamp) return timestamp;
|
||||
const createdAt = stringValue(record.created_at);
|
||||
return createdAt;
|
||||
};
|
||||
|
||||
const normalizeCPALogs = (data: Record<string, unknown>): LogsResponse => {
|
||||
const lines = Array.isArray(data.lines)
|
||||
? data.lines.filter((line): line is string => typeof line === 'string')
|
||||
: [];
|
||||
const latestTimestamp = unixSecondsFromValue(data['latest-timestamp']);
|
||||
const lineCount = Number(data['line-count']);
|
||||
|
||||
return {
|
||||
lines,
|
||||
lineCount: Number.isFinite(lineCount) ? lineCount : lines.length,
|
||||
latestCursor: latestTimestamp > 0 ? latestTimestamp : undefined,
|
||||
logBackendKind: 'file'
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeHomeLogs = (data: Record<string, unknown>): LogsResponse => {
|
||||
const rawLogs = Array.isArray(data.logs)
|
||||
? data.logs.filter((entry): entry is HomeLogRecord => isRecord(entry))
|
||||
: [];
|
||||
const orderedLogs = [...rawLogs].reverse();
|
||||
const lines = orderedLogs
|
||||
.map((record) => record.line)
|
||||
.filter((line): line is string => typeof line === 'string' && line.length > 0);
|
||||
const requestLogHomeIpById = orderedLogs.reduce<Record<string, string>>((acc, record) => {
|
||||
const requestId = stringValue(record.request_id);
|
||||
const homeIp = stringValue(record.home_ip);
|
||||
if (requestId && homeIp) {
|
||||
acc[requestId] = homeIp;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
const latestCursor = rawLogs.reduce<string | undefined>((latest, record) => {
|
||||
const cursor = homeCursorFromRecord(record);
|
||||
if (!cursor) return latest;
|
||||
if (!latest) return cursor;
|
||||
const latestTime = Date.parse(latest);
|
||||
const cursorTime = Date.parse(cursor);
|
||||
if (!Number.isFinite(latestTime) || !Number.isFinite(cursorTime)) return latest;
|
||||
return cursorTime > latestTime ? cursor : latest;
|
||||
}, undefined);
|
||||
|
||||
const total = Number(data.total);
|
||||
const limit = Number(data.limit);
|
||||
const offset = Number(data.offset);
|
||||
|
||||
return {
|
||||
lines,
|
||||
lineCount: Number.isFinite(total) ? total : lines.length,
|
||||
latestCursor,
|
||||
logBackendKind: 'home-db',
|
||||
requestLogHomeIpById,
|
||||
total: Number.isFinite(total) ? total : undefined,
|
||||
limit: Number.isFinite(limit) ? limit : undefined,
|
||||
offset: Number.isFinite(offset) ? offset : undefined
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeLogsResponse = (data: unknown): LogsResponse => {
|
||||
if (!isRecord(data)) {
|
||||
return { lines: [], lineCount: 0, logBackendKind: 'unknown' };
|
||||
}
|
||||
if (Array.isArray(data.logs)) return normalizeHomeLogs(data);
|
||||
if (Array.isArray(data.lines)) return normalizeCPALogs(data);
|
||||
return { lines: [], lineCount: 0, logBackendKind: 'unknown' };
|
||||
};
|
||||
|
||||
export const logsApi = {
|
||||
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> =>
|
||||
apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS }),
|
||||
async fetchLogs(params: LogsQuery = {}): Promise<LogsResponse> {
|
||||
const data = await apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS });
|
||||
return normalizeLogsResponse(data);
|
||||
},
|
||||
|
||||
clearLogs: () => apiClient.delete('/logs'),
|
||||
|
||||
@@ -40,8 +164,9 @@ export const logsApi = {
|
||||
timeout: LOGS_TIMEOUT_MS
|
||||
}),
|
||||
|
||||
downloadRequestLogById: (id: string) =>
|
||||
downloadRequestLogById: (id: string, homeIp?: string) =>
|
||||
apiClient.getRaw(`/request-log-by-id/${encodeURIComponent(id)}`, {
|
||||
params: homeIp ? { home_ip: homeIp } : undefined,
|
||||
responseType: 'blob',
|
||||
timeout: LOGS_TIMEOUT_MS
|
||||
}),
|
||||
|
||||
@@ -22,25 +22,18 @@ const serializeHeaders = (headers?: Record<string, string>) =>
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const RESPONSE_ONLY_FIELDS = ['auth-index', 'authIndex', 'auth_index'] as const;
|
||||
const RESPONSE_ONLY_FIELDS = ['auth-index'] as const;
|
||||
|
||||
const PROVIDER_KEY_FIELDS = [
|
||||
'api-key',
|
||||
'apiKey',
|
||||
'priority',
|
||||
'prefix',
|
||||
'base-url',
|
||||
'baseUrl',
|
||||
'base_url',
|
||||
'websockets',
|
||||
'proxy-url',
|
||||
'proxyUrl',
|
||||
'proxy_url',
|
||||
'headers',
|
||||
'models',
|
||||
'excluded-models',
|
||||
'excludedModels',
|
||||
'excluded_models',
|
||||
'cloak',
|
||||
] as const;
|
||||
|
||||
@@ -55,60 +48,17 @@ const OPENAI_PROVIDER_FIELDS = [
|
||||
'disabled',
|
||||
'prefix',
|
||||
'base-url',
|
||||
'baseUrl',
|
||||
'base_url',
|
||||
'api-key-entries',
|
||||
'apiKeyEntries',
|
||||
'api_key_entries',
|
||||
'api-keys',
|
||||
'apiKeys',
|
||||
'api_keys',
|
||||
'headers',
|
||||
'models',
|
||||
'test-model',
|
||||
'testModel',
|
||||
'test_model',
|
||||
] as const;
|
||||
|
||||
const MODEL_ALIAS_FIELDS = [
|
||||
'name',
|
||||
'id',
|
||||
'model',
|
||||
'alias',
|
||||
'display_name',
|
||||
'displayName',
|
||||
'priority',
|
||||
'test-model',
|
||||
'testModel',
|
||||
'test_model',
|
||||
] as const;
|
||||
const MODEL_ALIAS_FIELDS = ['name', 'alias', 'priority', 'test-model'] as const;
|
||||
|
||||
const API_KEY_ENTRY_FIELDS = [
|
||||
'api-key',
|
||||
'apiKey',
|
||||
'key',
|
||||
'proxy-url',
|
||||
'proxyUrl',
|
||||
'proxy_url',
|
||||
] as const;
|
||||
const API_KEY_ENTRY_FIELDS = ['api-key', 'proxy-url'] as const;
|
||||
|
||||
const CLOAK_FIELDS = [
|
||||
'mode',
|
||||
'strict-mode',
|
||||
'strictMode',
|
||||
'strict_mode',
|
||||
'sensitive-words',
|
||||
'sensitiveWords',
|
||||
'sensitive_words',
|
||||
] as const;
|
||||
|
||||
const RAW_SECTION_ALIASES: Record<string, readonly string[]> = {
|
||||
'gemini-api-key': ['gemini-api-key', 'geminiApiKey', 'geminiApiKeys'],
|
||||
'codex-api-key': ['codex-api-key', 'codexApiKey', 'codexApiKeys'],
|
||||
'claude-api-key': ['claude-api-key', 'claudeApiKey', 'claudeApiKeys'],
|
||||
'vertex-api-key': ['vertex-api-key', 'vertexApiKey', 'vertexApiKeys'],
|
||||
'openai-compatibility': ['openai-compatibility', 'openaiCompatibility', 'openAICompatibility'],
|
||||
};
|
||||
const CLOAK_FIELDS = ['mode', 'strict-mode', 'sensitive-words'] as const;
|
||||
|
||||
const getStringField = (record: Record<string, unknown>, keys: readonly string[]) => {
|
||||
for (const key of keys) {
|
||||
@@ -121,20 +71,19 @@ const getStringField = (record: Record<string, unknown>, keys: readonly string[]
|
||||
};
|
||||
|
||||
const providerKeyIdentity = (record: Record<string, unknown>) => {
|
||||
const apiKey = getStringField(record, ['api-key', 'apiKey']);
|
||||
const apiKey = getStringField(record, ['api-key']);
|
||||
if (!apiKey) return '';
|
||||
const baseUrl = getStringField(record, ['base-url', 'baseUrl', 'base_url']);
|
||||
const baseUrl = getStringField(record, ['base-url']);
|
||||
return `${apiKey}\u0000${baseUrl}`;
|
||||
};
|
||||
|
||||
const openAIProviderIdentity = (record: Record<string, unknown>) =>
|
||||
getStringField(record, ['name', 'id']);
|
||||
getStringField(record, ['name']);
|
||||
|
||||
const modelIdentity = (record: Record<string, unknown>) =>
|
||||
getStringField(record, ['name', 'id', 'model']);
|
||||
const modelIdentity = (record: Record<string, unknown>) => getStringField(record, ['name']);
|
||||
|
||||
const apiKeyEntryIdentity = (record: Record<string, unknown>) =>
|
||||
getStringField(record, ['api-key', 'apiKey', 'key']);
|
||||
getStringField(record, ['api-key']);
|
||||
|
||||
const cloneWithoutKnownFields = (
|
||||
raw: unknown,
|
||||
@@ -206,14 +155,10 @@ const mergeKnownRecordList = (
|
||||
});
|
||||
};
|
||||
|
||||
const getRawSectionList = (rawConfig: unknown, section: string) => {
|
||||
const getRawSectionList = (rawConfig: unknown, section: string): unknown[] => {
|
||||
if (!isRecord(rawConfig)) return [];
|
||||
const aliases = RAW_SECTION_ALIASES[section] ?? [section];
|
||||
for (const alias of aliases) {
|
||||
const value = rawConfig[alias];
|
||||
if (Array.isArray(value)) return value;
|
||||
}
|
||||
return [];
|
||||
const value = rawConfig[section];
|
||||
return Array.isArray(value) ? value : [];
|
||||
};
|
||||
|
||||
const mergeModelPayloads = (raw: unknown, models: unknown) =>
|
||||
@@ -246,9 +191,7 @@ const mergeProviderKeyPayload = (
|
||||
|
||||
const mergeOpenAIProviderPayload = (raw: unknown, payload: Record<string, unknown>) => {
|
||||
const next = mergeKnownFields(raw, payload, OPENAI_PROVIDER_FIELDS);
|
||||
const rawApiKeyEntries = isRecord(raw)
|
||||
? (raw['api-key-entries'] ?? raw.apiKeyEntries)
|
||||
: undefined;
|
||||
const rawApiKeyEntries = isRecord(raw) ? raw['api-key-entries'] : undefined;
|
||||
const apiKeyEntries = payload['api-key-entries'];
|
||||
if (Array.isArray(apiKeyEntries)) {
|
||||
next['api-key-entries'] = mergeKnownRecordList(
|
||||
@@ -287,10 +230,9 @@ const buildPreservedList = async <T>(
|
||||
};
|
||||
|
||||
const extractArrayPayload = (data: unknown, key: string): unknown[] => {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (!isRecord(data)) return [];
|
||||
const candidate = data[key] ?? data.items ?? data.data ?? data;
|
||||
return Array.isArray(candidate) ? candidate : [];
|
||||
const list = data[key];
|
||||
return Array.isArray(list) ? list : [];
|
||||
};
|
||||
|
||||
const buildProviderDeleteQuery = (apiKey: string, baseUrl?: string) => {
|
||||
@@ -323,8 +265,6 @@ const serializeModelAliases = (models?: ModelAlias[]) =>
|
||||
const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
|
||||
const payload: Record<string, unknown> = { 'api-key': entry.apiKey };
|
||||
if (entry.proxyUrl) payload['proxy-url'] = entry.proxyUrl;
|
||||
const headers = serializeHeaders(entry.headers);
|
||||
if (headers) payload.headers = headers;
|
||||
return payload;
|
||||
};
|
||||
|
||||
|
||||
@@ -15,17 +15,8 @@ import { buildHeaderObject } from '@/utils/headers';
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const normalizeBoolean = (value: unknown): boolean | undefined => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (['true', '1', 'yes', 'y', 'on'].includes(trimmed)) return true;
|
||||
if (['false', '0', 'no', 'n', 'off'].includes(trimmed)) return false;
|
||||
}
|
||||
return Boolean(value);
|
||||
};
|
||||
const normalizeBoolean = (value: unknown): boolean | undefined =>
|
||||
typeof value === 'boolean' ? value : undefined;
|
||||
|
||||
const normalizeModelAliases = (models: unknown): ModelAlias[] => {
|
||||
if (!Array.isArray(models)) return [];
|
||||
@@ -38,11 +29,11 @@ const normalizeModelAliases = (models: unknown): ModelAlias[] => {
|
||||
}
|
||||
if (!isRecord(item)) return null;
|
||||
|
||||
const name = item.name || item.id || item.model;
|
||||
const name = item.name;
|
||||
if (!name) return null;
|
||||
const alias = item.alias || item.display_name || item.displayName;
|
||||
const priority = item.priority ?? item['priority'];
|
||||
const testModel = item['test-model'] ?? item.testModel;
|
||||
const alias = item.alias;
|
||||
const priority = item.priority;
|
||||
const testModel = item['test-model'];
|
||||
const entry: ModelAlias = { name: String(name) };
|
||||
if (alias && alias !== name) {
|
||||
entry.alias = String(alias);
|
||||
@@ -103,21 +94,16 @@ const normalizeAuthIndex = (value: unknown): string | undefined => {
|
||||
const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
|
||||
if (entry === undefined || entry === null) return null;
|
||||
const record = isRecord(entry) ? entry : null;
|
||||
const apiKey =
|
||||
record?.['api-key'] ?? record?.apiKey ?? record?.key ?? (typeof entry === 'string' ? entry : '');
|
||||
const apiKey = record?.['api-key'] ?? (typeof entry === 'string' ? entry : '');
|
||||
const trimmed = String(apiKey || '').trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
|
||||
const headers = record ? normalizeHeaders(record.headers) : undefined;
|
||||
const authIndex = normalizeAuthIndex(
|
||||
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
|
||||
);
|
||||
const proxyUrl = record?.['proxy-url'];
|
||||
const authIndex = normalizeAuthIndex(record?.['auth-index']);
|
||||
|
||||
const result: ApiKeyEntry = {
|
||||
apiKey: trimmed,
|
||||
proxyUrl: proxyUrl ? String(proxyUrl) : undefined,
|
||||
headers
|
||||
proxyUrl: proxyUrl ? String(proxyUrl) : undefined
|
||||
};
|
||||
if (authIndex) result.authIndex = authIndex;
|
||||
return result;
|
||||
@@ -126,58 +112,47 @@ const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
|
||||
const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => {
|
||||
if (item === undefined || item === null) return null;
|
||||
const record = isRecord(item) ? item : null;
|
||||
const apiKey = record?.['api-key'] ?? record?.apiKey ?? (typeof item === 'string' ? item : '');
|
||||
const apiKey = record?.['api-key'] ?? (typeof item === 'string' ? item : '');
|
||||
const trimmed = String(apiKey || '').trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const config: ProviderKeyConfig = { apiKey: trimmed };
|
||||
const priority = record?.priority ?? record?.['priority'];
|
||||
const priority = record?.priority;
|
||||
if (priority !== undefined && priority !== null && String(priority).trim() !== '') {
|
||||
const parsed = Number(priority);
|
||||
if (Number.isFinite(parsed)) {
|
||||
config.priority = parsed;
|
||||
}
|
||||
}
|
||||
const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']);
|
||||
const prefix = normalizePrefix(record?.prefix);
|
||||
if (prefix) config.prefix = prefix;
|
||||
const baseUrl = record ? record['base-url'] ?? record.baseUrl : undefined;
|
||||
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
|
||||
const baseUrl = record?.['base-url'];
|
||||
const proxyUrl = record?.['proxy-url'];
|
||||
if (baseUrl) config.baseUrl = String(baseUrl);
|
||||
const websockets = normalizeBoolean(record?.websockets ?? record?.['websockets']);
|
||||
const websockets = normalizeBoolean(record?.websockets);
|
||||
if (websockets !== undefined) config.websockets = websockets;
|
||||
if (proxyUrl) config.proxyUrl = String(proxyUrl);
|
||||
const headers = normalizeHeaders(record?.headers);
|
||||
if (headers) config.headers = headers;
|
||||
const models = normalizeModelAliases(record?.models);
|
||||
if (models.length) config.models = models;
|
||||
const excludedModels = normalizeExcludedModels(
|
||||
record?.['excluded-models'] ??
|
||||
record?.excludedModels ??
|
||||
record?.['excluded_models'] ??
|
||||
record?.excluded_models
|
||||
);
|
||||
const excludedModels = normalizeExcludedModels(record?.['excluded-models']);
|
||||
if (excludedModels.length) config.excludedModels = excludedModels;
|
||||
const authIndex = normalizeAuthIndex(
|
||||
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
|
||||
);
|
||||
const authIndex = normalizeAuthIndex(record?.['auth-index']);
|
||||
if (authIndex) config.authIndex = authIndex;
|
||||
|
||||
const cloakRaw = record?.cloak;
|
||||
if (isRecord(cloakRaw)) {
|
||||
const cloak: CloakConfig = {};
|
||||
const mode = cloakRaw.mode ?? cloakRaw['mode'];
|
||||
const mode = cloakRaw.mode;
|
||||
if (typeof mode === 'string' && mode.trim()) {
|
||||
cloak.mode = mode.trim();
|
||||
}
|
||||
const strictMode = normalizeBoolean(
|
||||
cloakRaw['strict-mode'] ?? cloakRaw.strictMode ?? cloakRaw.strict_mode
|
||||
);
|
||||
const strictMode = normalizeBoolean(cloakRaw['strict-mode']);
|
||||
if (strictMode !== undefined) {
|
||||
cloak.strictMode = strictMode;
|
||||
}
|
||||
const sensitiveWords = normalizeExcludedModels(
|
||||
cloakRaw['sensitive-words'] ?? cloakRaw.sensitiveWords ?? cloakRaw.sensitive_words
|
||||
);
|
||||
const sensitiveWords = normalizeExcludedModels(cloakRaw['sensitive-words']);
|
||||
if (sensitiveWords.length) {
|
||||
cloak.sensitiveWords = sensitiveWords;
|
||||
}
|
||||
@@ -192,7 +167,7 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null =>
|
||||
const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
|
||||
if (item === undefined || item === null) return null;
|
||||
const record = isRecord(item) ? item : null;
|
||||
let apiKey = record?.['api-key'] ?? record?.apiKey;
|
||||
let apiKey = record?.['api-key'];
|
||||
if (!apiKey && typeof item === 'string') {
|
||||
apiKey = item;
|
||||
}
|
||||
@@ -200,53 +175,46 @@ const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
|
||||
if (!trimmed) return null;
|
||||
|
||||
const config: GeminiKeyConfig = { apiKey: trimmed };
|
||||
const priority = record?.priority ?? record?.['priority'];
|
||||
const priority = record?.priority;
|
||||
if (priority !== undefined && priority !== null && String(priority).trim() !== '') {
|
||||
const parsed = Number(priority);
|
||||
if (Number.isFinite(parsed)) {
|
||||
config.priority = parsed;
|
||||
}
|
||||
}
|
||||
const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']);
|
||||
const prefix = normalizePrefix(record?.prefix);
|
||||
if (prefix) config.prefix = prefix;
|
||||
const baseUrl = record ? record['base-url'] ?? record.baseUrl ?? record['base_url'] : undefined;
|
||||
const baseUrl = record?.['base-url'];
|
||||
if (baseUrl) config.baseUrl = String(baseUrl);
|
||||
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl ?? record['proxy_url'] : undefined;
|
||||
const proxyUrl = record?.['proxy-url'];
|
||||
if (proxyUrl) config.proxyUrl = String(proxyUrl);
|
||||
const models = normalizeModelAliases(record?.models);
|
||||
if (models.length) config.models = models;
|
||||
const headers = normalizeHeaders(record?.headers);
|
||||
if (headers) config.headers = headers;
|
||||
const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels);
|
||||
const excludedModels = normalizeExcludedModels(record?.['excluded-models']);
|
||||
if (excludedModels.length) config.excludedModels = excludedModels;
|
||||
const authIndex = normalizeAuthIndex(
|
||||
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
|
||||
);
|
||||
const authIndex = normalizeAuthIndex(record?.['auth-index']);
|
||||
if (authIndex) config.authIndex = authIndex;
|
||||
return config;
|
||||
};
|
||||
|
||||
const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null => {
|
||||
if (!isRecord(provider)) return null;
|
||||
const name = provider.name || provider.id;
|
||||
const baseUrl = provider['base-url'] ?? provider.baseUrl;
|
||||
const name = provider.name;
|
||||
const baseUrl = provider['base-url'];
|
||||
if (!name || !baseUrl) return null;
|
||||
|
||||
let apiKeyEntries: ApiKeyEntry[] = [];
|
||||
if (Array.isArray(provider['api-key-entries'])) {
|
||||
apiKeyEntries = provider['api-key-entries']
|
||||
.map((entry) => normalizeApiKeyEntry(entry))
|
||||
.filter(Boolean) as ApiKeyEntry[];
|
||||
} else if (Array.isArray(provider['api-keys'])) {
|
||||
apiKeyEntries = provider['api-keys']
|
||||
.map((key) => normalizeApiKeyEntry({ 'api-key': key }))
|
||||
.filter(Boolean) as ApiKeyEntry[];
|
||||
}
|
||||
const apiKeyEntries = Array.isArray(provider['api-key-entries'])
|
||||
? (provider['api-key-entries']
|
||||
.map((entry) => normalizeApiKeyEntry(entry))
|
||||
.filter(Boolean) as ApiKeyEntry[])
|
||||
: [];
|
||||
|
||||
const headers = normalizeHeaders(provider.headers);
|
||||
const models = normalizeModelAliases(provider.models);
|
||||
const priority = provider.priority ?? provider['priority'];
|
||||
const testModel = provider['test-model'] ?? provider.testModel;
|
||||
const priority = provider.priority;
|
||||
const testModel = provider['test-model'];
|
||||
|
||||
const result: OpenAIProviderConfig = {
|
||||
name: String(name),
|
||||
@@ -254,17 +222,15 @@ const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null
|
||||
apiKeyEntries
|
||||
};
|
||||
|
||||
const disabled = normalizeBoolean(provider.disabled ?? provider['disabled']);
|
||||
const disabled = normalizeBoolean(provider.disabled);
|
||||
if (disabled !== undefined) result.disabled = disabled;
|
||||
const prefix = normalizePrefix(provider.prefix ?? provider['prefix']);
|
||||
const prefix = normalizePrefix(provider.prefix);
|
||||
if (prefix) result.prefix = prefix;
|
||||
if (headers) result.headers = headers;
|
||||
if (models.length) result.models = models;
|
||||
if (priority !== undefined) result.priority = Number(priority);
|
||||
if (testModel) result.testModel = String(testModel);
|
||||
const authIndex = normalizeAuthIndex(
|
||||
provider['auth-index'] ?? provider.authIndex ?? provider['auth_index']
|
||||
);
|
||||
const authIndex = normalizeAuthIndex(provider['auth-index']);
|
||||
if (authIndex) result.authIndex = authIndex;
|
||||
return result;
|
||||
};
|
||||
@@ -290,8 +256,8 @@ const normalizeAmpcodeModelMappings = (input: unknown): AmpcodeModelMapping[] =>
|
||||
|
||||
input.forEach((entry) => {
|
||||
if (!isRecord(entry)) return;
|
||||
const from = String(entry.from ?? entry['from'] ?? '').trim();
|
||||
const to = String(entry.to ?? entry['to'] ?? '').trim();
|
||||
const from = String(entry.from ?? '').trim();
|
||||
const to = String(entry.to ?? '').trim();
|
||||
if (!from || !to) return;
|
||||
const key = from.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
@@ -311,12 +277,10 @@ const normalizeAmpcodeUpstreamApiKeys = (input: unknown): AmpcodeUpstreamApiKeyM
|
||||
input.forEach((entry) => {
|
||||
if (!isRecord(entry)) return;
|
||||
|
||||
const upstreamApiKey = String(
|
||||
entry['upstream-api-key'] ?? entry.upstreamApiKey ?? entry['upstream_api_key'] ?? ''
|
||||
).trim();
|
||||
const upstreamApiKey = String(entry['upstream-api-key'] ?? '').trim();
|
||||
if (!upstreamApiKey || seen.has(upstreamApiKey)) return;
|
||||
|
||||
const rawApiKeys = entry['api-keys'] ?? entry.apiKeys ?? entry['api_keys'] ?? [];
|
||||
const rawApiKeys = entry['api-keys'] ?? [];
|
||||
const apiKeys = Array.isArray(rawApiKeys)
|
||||
? Array.from(new Set(rawApiKeys.map((item) => String(item ?? '').trim()).filter(Boolean)))
|
||||
: [];
|
||||
@@ -335,28 +299,22 @@ const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined =>
|
||||
const source = sourceRaw;
|
||||
|
||||
const config: AmpcodeConfig = {};
|
||||
const upstreamUrl = source['upstream-url'] ?? source.upstreamUrl ?? source['upstream_url'];
|
||||
const upstreamUrl = source['upstream-url'];
|
||||
if (upstreamUrl) config.upstreamUrl = String(upstreamUrl);
|
||||
const upstreamApiKey = source['upstream-api-key'] ?? source.upstreamApiKey ?? source['upstream_api_key'];
|
||||
const upstreamApiKey = source['upstream-api-key'];
|
||||
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey);
|
||||
|
||||
const upstreamApiKeys = normalizeAmpcodeUpstreamApiKeys(
|
||||
source['upstream-api-keys'] ?? source.upstreamApiKeys ?? source['upstream_api_keys']
|
||||
);
|
||||
const upstreamApiKeys = normalizeAmpcodeUpstreamApiKeys(source['upstream-api-keys']);
|
||||
if (upstreamApiKeys.length) {
|
||||
config.upstreamApiKeys = upstreamApiKeys;
|
||||
}
|
||||
|
||||
const forceModelMappings = normalizeBoolean(
|
||||
source['force-model-mappings'] ?? source.forceModelMappings ?? source['force_model_mappings']
|
||||
);
|
||||
const forceModelMappings = normalizeBoolean(source['force-model-mappings']);
|
||||
if (forceModelMappings !== undefined) {
|
||||
config.forceModelMappings = forceModelMappings;
|
||||
}
|
||||
|
||||
const modelMappings = normalizeAmpcodeModelMappings(
|
||||
source['model-mappings'] ?? source.modelMappings ?? source['model_mappings']
|
||||
);
|
||||
const modelMappings = normalizeAmpcodeModelMappings(source['model-mappings']);
|
||||
if (modelMappings.length) {
|
||||
config.modelMappings = modelMappings;
|
||||
}
|
||||
@@ -374,10 +332,10 @@ export const normalizeConfigResponse = (raw: unknown): Config => {
|
||||
}
|
||||
|
||||
config.debug = normalizeBoolean(raw.debug);
|
||||
const proxyUrl = raw['proxy-url'] ?? raw.proxyUrl;
|
||||
const proxyUrl = raw['proxy-url'];
|
||||
config.proxyUrl =
|
||||
typeof proxyUrl === 'string' ? proxyUrl : proxyUrl === undefined || proxyUrl === null ? undefined : String(proxyUrl);
|
||||
const requestRetry = raw['request-retry'] ?? raw.requestRetry;
|
||||
const requestRetry = raw['request-retry'];
|
||||
if (typeof requestRetry === 'number' && Number.isFinite(requestRetry)) {
|
||||
config.requestRetry = requestRetry;
|
||||
} else if (typeof requestRetry === 'string' && requestRetry.trim() !== '') {
|
||||
@@ -387,22 +345,18 @@ export const normalizeConfigResponse = (raw: unknown): Config => {
|
||||
}
|
||||
}
|
||||
|
||||
const quota = raw['quota-exceeded'] ?? raw.quotaExceeded;
|
||||
const quota = raw['quota-exceeded'];
|
||||
if (isRecord(quota)) {
|
||||
config.quotaExceeded = {
|
||||
switchProject: normalizeBoolean(quota['switch-project'] ?? quota.switchProject),
|
||||
switchPreviewModel: normalizeBoolean(
|
||||
quota['switch-preview-model'] ?? quota.switchPreviewModel
|
||||
),
|
||||
antigravityCredits: normalizeBoolean(
|
||||
quota['antigravity-credits'] ?? quota.antigravityCredits
|
||||
)
|
||||
switchProject: normalizeBoolean(quota['switch-project']),
|
||||
switchPreviewModel: normalizeBoolean(quota['switch-preview-model']),
|
||||
antigravityCredits: normalizeBoolean(quota['antigravity-credits'])
|
||||
};
|
||||
}
|
||||
|
||||
config.requestLog = normalizeBoolean(raw['request-log'] ?? raw.requestLog);
|
||||
config.loggingToFile = normalizeBoolean(raw['logging-to-file'] ?? raw.loggingToFile);
|
||||
const logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb;
|
||||
config.requestLog = normalizeBoolean(raw['request-log']);
|
||||
config.loggingToFile = normalizeBoolean(raw['logging-to-file']);
|
||||
const logsMaxTotalSizeMb = raw['logs-max-total-size-mb'];
|
||||
if (typeof logsMaxTotalSizeMb === 'number' && Number.isFinite(logsMaxTotalSizeMb)) {
|
||||
config.logsMaxTotalSizeMb = logsMaxTotalSizeMb;
|
||||
} else if (typeof logsMaxTotalSizeMb === 'string' && logsMaxTotalSizeMb.trim() !== '') {
|
||||
@@ -411,49 +365,47 @@ export const normalizeConfigResponse = (raw: unknown): Config => {
|
||||
config.logsMaxTotalSizeMb = parsed;
|
||||
}
|
||||
}
|
||||
config.wsAuth = normalizeBoolean(raw['ws-auth'] ?? raw.wsAuth);
|
||||
config.forceModelPrefix = normalizeBoolean(raw['force-model-prefix'] ?? raw.forceModelPrefix);
|
||||
config.wsAuth = normalizeBoolean(raw['ws-auth']);
|
||||
config.forceModelPrefix = normalizeBoolean(raw['force-model-prefix']);
|
||||
const routing = raw.routing;
|
||||
const strategyRaw = isRecord(routing)
|
||||
? (routing.strategy ?? routing['strategy'])
|
||||
: (raw['routing-strategy'] ?? raw.routingStrategy);
|
||||
const strategyRaw = isRecord(routing) ? routing.strategy : undefined;
|
||||
if (strategyRaw !== undefined && strategyRaw !== null) {
|
||||
config.routingStrategy = String(strategyRaw);
|
||||
}
|
||||
const apiKeysRaw = raw['api-keys'] ?? raw.apiKeys;
|
||||
const apiKeysRaw = raw['api-keys'];
|
||||
if (Array.isArray(apiKeysRaw)) {
|
||||
config.apiKeys = apiKeysRaw.map((key) => String(key)).filter((key) => key.trim() !== '');
|
||||
}
|
||||
|
||||
const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys;
|
||||
const geminiList = raw['gemini-api-key'];
|
||||
if (Array.isArray(geminiList)) {
|
||||
config.geminiApiKeys = geminiList
|
||||
.map((item) => normalizeGeminiKeyConfig(item))
|
||||
.filter(Boolean) as GeminiKeyConfig[];
|
||||
}
|
||||
|
||||
const codexList = raw['codex-api-key'] ?? raw.codexApiKey ?? raw.codexApiKeys;
|
||||
const codexList = raw['codex-api-key'];
|
||||
if (Array.isArray(codexList)) {
|
||||
config.codexApiKeys = codexList
|
||||
.map((item) => normalizeProviderKeyConfig(item))
|
||||
.filter(Boolean) as ProviderKeyConfig[];
|
||||
}
|
||||
|
||||
const claudeList = raw['claude-api-key'] ?? raw.claudeApiKey ?? raw.claudeApiKeys;
|
||||
const claudeList = raw['claude-api-key'];
|
||||
if (Array.isArray(claudeList)) {
|
||||
config.claudeApiKeys = claudeList
|
||||
.map((item) => normalizeProviderKeyConfig(item))
|
||||
.filter(Boolean) as ProviderKeyConfig[];
|
||||
}
|
||||
|
||||
const vertexList = raw['vertex-api-key'] ?? raw.vertexApiKey ?? raw.vertexApiKeys;
|
||||
const vertexList = raw['vertex-api-key'];
|
||||
if (Array.isArray(vertexList)) {
|
||||
config.vertexApiKeys = vertexList
|
||||
.map((item) => normalizeProviderKeyConfig(item))
|
||||
.filter(Boolean) as ProviderKeyConfig[];
|
||||
}
|
||||
|
||||
const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility;
|
||||
const openaiList = raw['openai-compatibility'];
|
||||
if (Array.isArray(openaiList)) {
|
||||
config.openaiCompatibility = openaiList
|
||||
.map((item) => normalizeOpenAIProvider(item))
|
||||
@@ -465,7 +417,7 @@ export const normalizeConfigResponse = (raw: unknown): Config => {
|
||||
config.ampcode = ampcode;
|
||||
}
|
||||
|
||||
const oauthExcluded = normalizeOauthExcluded(raw['oauth-excluded-models'] ?? raw.oauthExcludedModels);
|
||||
const oauthExcluded = normalizeOauthExcluded(raw['oauth-excluded-models']);
|
||||
if (oauthExcluded) {
|
||||
config.oauthExcludedModels = oauthExcluded;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,24 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { ServerRuntimeKind } from '@/types';
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object';
|
||||
|
||||
export const versionApi = {
|
||||
checkLatest: () => apiClient.get<Record<string, unknown>>('/latest-version')
|
||||
checkLatest: () => apiClient.get<Record<string, unknown>>('/latest-version'),
|
||||
|
||||
async detectRuntimeKind(): Promise<ServerRuntimeKind> {
|
||||
try {
|
||||
const data = await apiClient.get('/nodes');
|
||||
return isRecord(data) && Array.isArray(data.nodes) ? 'home' : 'unknown';
|
||||
} catch (error: unknown) {
|
||||
const status = isRecord(error) ? error.status : undefined;
|
||||
if (status === 404 || status === 405) {
|
||||
return 'cpa';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { AuthState, LoginCredentials, ConnectionStatus } from '@/types';
|
||||
import type { AuthState, LoginCredentials, ConnectionStatus, ServerRuntimeKind } from '@/types';
|
||||
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
||||
import { obfuscatedStorage } from '@/services/storage/secureStorage';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import { versionApi } from '@/services/api/version';
|
||||
import { useConfigStore } from './useConfigStore';
|
||||
import { useModelsStore } from './useModelsStore';
|
||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||
@@ -22,12 +23,26 @@ interface AuthStoreState extends AuthState {
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<boolean>;
|
||||
restoreSession: () => Promise<boolean>;
|
||||
updateServerVersion: (version: string | null, buildDate?: string | null) => void;
|
||||
updateServerVersion: (
|
||||
version: string | null,
|
||||
buildDate?: string | null,
|
||||
runtimeKind?: ServerRuntimeKind | null
|
||||
) => void;
|
||||
updateServerRuntimeKind: (runtimeKind: ServerRuntimeKind) => void;
|
||||
updateConnectionStatus: (status: ConnectionStatus, error?: string | null) => void;
|
||||
}
|
||||
|
||||
let restoreSessionPromise: Promise<boolean> | null = null;
|
||||
|
||||
const detectRuntimeKind = async (): Promise<ServerRuntimeKind> => {
|
||||
try {
|
||||
return await versionApi.detectRuntimeKind();
|
||||
} catch (error) {
|
||||
console.warn('Runtime kind detection failed:', error);
|
||||
return 'unknown';
|
||||
}
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AuthStoreState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -38,6 +53,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
rememberPassword: false,
|
||||
serverVersion: null,
|
||||
serverBuildDate: null,
|
||||
serverRuntimeKind: 'unknown',
|
||||
connectionStatus: 'disconnected',
|
||||
connectionError: null,
|
||||
|
||||
@@ -93,7 +109,12 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
const rememberPassword = credentials.rememberPassword ?? get().rememberPassword ?? false;
|
||||
|
||||
try {
|
||||
set({ connectionStatus: 'connecting' });
|
||||
set({
|
||||
connectionStatus: 'connecting',
|
||||
serverVersion: null,
|
||||
serverBuildDate: null,
|
||||
serverRuntimeKind: 'unknown'
|
||||
});
|
||||
useModelsStore.getState().clearCache();
|
||||
|
||||
// 配置 API 客户端
|
||||
@@ -104,6 +125,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
|
||||
// 测试连接 - 获取配置
|
||||
await useConfigStore.getState().fetchConfig(undefined, true);
|
||||
const runtimeKind = await detectRuntimeKind();
|
||||
|
||||
// 登录成功
|
||||
set({
|
||||
@@ -112,7 +134,8 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
managementKey,
|
||||
rememberPassword,
|
||||
connectionStatus: 'connected',
|
||||
connectionError: null
|
||||
connectionError: null,
|
||||
...(runtimeKind !== 'unknown' ? { serverRuntimeKind: runtimeKind } : {})
|
||||
});
|
||||
if (rememberPassword) {
|
||||
localStorage.setItem('isLoggedIn', 'true');
|
||||
@@ -145,6 +168,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
managementKey: '',
|
||||
serverVersion: null,
|
||||
serverBuildDate: null,
|
||||
serverRuntimeKind: 'unknown',
|
||||
connectionStatus: 'disconnected',
|
||||
connectionError: null
|
||||
});
|
||||
@@ -165,10 +189,12 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
|
||||
// 验证连接
|
||||
await useConfigStore.getState().fetchConfig();
|
||||
const runtimeKind = await detectRuntimeKind();
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
connectionStatus: 'connected'
|
||||
connectionStatus: 'connected',
|
||||
...(runtimeKind !== 'unknown' ? { serverRuntimeKind: runtimeKind } : {})
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -182,8 +208,16 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
},
|
||||
|
||||
// 更新服务器版本
|
||||
updateServerVersion: (version, buildDate) => {
|
||||
set({ serverVersion: version || null, serverBuildDate: buildDate || null });
|
||||
updateServerVersion: (version, buildDate, runtimeKind) => {
|
||||
set((state) => ({
|
||||
serverVersion: version || null,
|
||||
serverBuildDate: buildDate || null,
|
||||
serverRuntimeKind: runtimeKind || state.serverRuntimeKind
|
||||
}));
|
||||
},
|
||||
|
||||
updateServerRuntimeKind: (runtimeKind) => {
|
||||
set({ serverRuntimeKind: runtimeKind });
|
||||
},
|
||||
|
||||
// 更新连接状态
|
||||
@@ -213,7 +247,8 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
...(state.rememberPassword ? { managementKey: state.managementKey } : {}),
|
||||
rememberPassword: state.rememberPassword,
|
||||
serverVersion: state.serverVersion,
|
||||
serverBuildDate: state.serverBuildDate
|
||||
serverBuildDate: state.serverBuildDate,
|
||||
serverRuntimeKind: state.serverRuntimeKind
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -229,7 +264,13 @@ if (typeof window !== 'undefined') {
|
||||
'server-version-update',
|
||||
((e: CustomEvent) => {
|
||||
const detail = e.detail || {};
|
||||
useAuthStore.getState().updateServerVersion(detail.version || null, detail.buildDate || null);
|
||||
const runtimeKind =
|
||||
detail.runtimeKind === 'cpa' || detail.runtimeKind === 'home'
|
||||
? detail.runtimeKind
|
||||
: null;
|
||||
useAuthStore
|
||||
.getState()
|
||||
.updateServerVersion(detail.version || null, detail.buildDate || null, runtimeKind);
|
||||
}) as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ export interface AuthState {
|
||||
rememberPassword: boolean;
|
||||
serverVersion: string | null;
|
||||
serverBuildDate: string | null;
|
||||
serverRuntimeKind: ServerRuntimeKind;
|
||||
}
|
||||
|
||||
// 连接状态
|
||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'connecting' | 'error';
|
||||
export type ServerRuntimeKind = 'unknown' | 'cpa' | 'home';
|
||||
|
||||
export interface ConnectionInfo {
|
||||
status: ConnectionStatus;
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface ModelAlias {
|
||||
export interface ApiKeyEntry {
|
||||
apiKey: string;
|
||||
proxyUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
authIndex?: string;
|
||||
}
|
||||
|
||||
|
||||
+14
-2
@@ -16,8 +16,20 @@ export const CACHE_EXPIRY_MS = 30 * 1000; // 与基线保持一致,减少管
|
||||
export const DEFAULT_API_PORT = 8317;
|
||||
export const MANAGEMENT_API_PREFIX = '/v0/management';
|
||||
export const REQUEST_TIMEOUT_MS = 30 * 1000;
|
||||
export const VERSION_HEADER_KEYS = ['x-cpa-version', 'x-server-version'];
|
||||
export const BUILD_DATE_HEADER_KEYS = ['x-cpa-build-date', 'x-server-build-date'];
|
||||
export const CPA_VERSION_HEADER_KEYS = ['x-cpa-version'];
|
||||
export const CPA_BUILD_DATE_HEADER_KEYS = ['x-cpa-build-date'];
|
||||
export const HOME_VERSION_HEADER_KEYS = ['x-cpa-home-version'];
|
||||
export const HOME_BUILD_DATE_HEADER_KEYS = ['x-cpa-home-build-date'];
|
||||
export const VERSION_HEADER_KEYS = [
|
||||
...HOME_VERSION_HEADER_KEYS,
|
||||
...CPA_VERSION_HEADER_KEYS,
|
||||
'x-server-version'
|
||||
];
|
||||
export const BUILD_DATE_HEADER_KEYS = [
|
||||
...HOME_BUILD_DATE_HEADER_KEYS,
|
||||
...CPA_BUILD_DATE_HEADER_KEYS,
|
||||
'x-server-build-date'
|
||||
];
|
||||
|
||||
// 日志相关
|
||||
export const LOGS_TIMEOUT_MS = 60 * 1000;
|
||||
|
||||
Reference in New Issue
Block a user