Compare commits

...

21 Commits

  • Merge pull request #303 from router-for-me:home
    feat(logs): enhance logging functionality and UI updates
  • feat(logs): enhance logging functionality and UI updates
    - Refactored MainLayout to always show logs in the sidebar.
    - Updated i18n files for English, Russian, Simplified Chinese, and Traditional Chinese to include new log-related messages.
    - Improved LogsPage styling for better layout and added runtime notices.
    - Enhanced LogsPage logic to handle different server runtime kinds (CPA and Home).
    - Added new API methods to handle logs fetching and request log downloading with runtime checks.
    - Introduced runtime kind detection during authentication and connection status updates.
    - Updated types to include server runtime kind and adjusted API responses accordingly.
  • fix(providers): remove per-key headers input for openai-compatibility
    The backend OpenAICompatibilityAPIKey struct only has api-key and
    proxy-url (internal/config/config.go:570-577). PUT /openai-compatibility
    unmarshals into this struct (config_lists.go:439) and discards any
    unknown field, so per-entry headers submitted from the UI never persist
    and never reach the runtime request path. The GET response wrapper
    also has no headers field (config_auth_index.go:31-34).
    
    Drop the per-entry headers input from BaseProviderForm and the
    ApiKeyEntry/ApiKeyEntryInput types. Connectivity test and model
    discovery stop reading entry-level headers — they continue to apply
    provider-level headers, which is the supported contract.
  • fix(auth-files): infer counts/names for single-item success path
    Backend's POST /auth-files and DELETE /auth-files single-item paths
    return only {status:"ok"}, with no uploaded/deleted/files
    (auth_files.go:680 and :794). Multi-item paths return the full payload.
    
    85c8b34 simplified the normalizer assuming the full payload was always
    present, which caused single-file uploads and single-item deletes to be
    read as "0 succeeded" — the upload page skipped its success toast and
    list refresh, and batch delete reported "(0)" with no row removal.
    
    Re-introduce a narrow fallback: when failed is empty and the count
    field is omitted, derive uploaded/deleted and files from requestedNames.
    The fallback only kicks in for the documented single-item shape, not
    for the partial/failure paths.
  • refactor(transformers): tighten normalizeBoolean to accept real booleans only
    All bool-tagged config fields on the backend are Go bools and serialize
    as JSON true/false (e.g. Debug, RequestLog, WebsocketAuth, Disabled,
    Websockets, ForceModelPrefix). normalizeBoolean is only called against
    those response paths, so the number / string / Boolean(value) fallbacks
    never fire.
  • refactor(auth-files): simplify batch response and field normalization
    Backend batch upload/delete handlers always return a complete payload:
    status (string), uploaded/deleted (number), files (string array), and
    failed (only on partial). See auth_files.go:702-711. The fallback chains
    that re-derived uploaded/deleted counts and file names from the
    requestedNames list were unreachable.
    
    Likewise, runtime_only is always a real bool from Go (auth_files.go:403),
    and entry.modified is never emitted, so isRuntimeOnlyEntry collapses
    to a strict equality check and readDateField drops the modified alias.
  • refactor(api-call): drop dead statusCode/headers field aliases
    The backend api-call response schema (api_tools.go:54-56) fixes the
    field names to status_code / header / body. camelCase statusCode and
    plural headers are never emitted.
  • refactor(config): drop dead key aliases in single-field GETs
    Backend handlers emit single-field GETs with a single kebab-case key:
    - GetLogsMaxTotalSizeMB returns {"logs-max-total-size-mb": n}
      (config_basic.go:206)
    - GetForceModelPrefix returns {"force-model-prefix": b}
      (config_basic.go:276)
    - GetRoutingStrategy returns {"strategy": s}
      (config_basic.go:295-301)
    
    The camelCase fallbacks (logsMaxTotalSizeMb / forceModelPrefix /
    routing-strategy / routingStrategy) were never reachable.
  • refactor(providers): simplify section and array payload extraction
    Backend list endpoints (config_lists.go: GetGeminiKeys / GetClaudeKeys /
    GetCodexKeys / GetOpenAICompat / GetVertexCompatKeys) always wrap the
    result as {"<kebab-section>": [...]}, never as a raw array, never under
    "items" or "data". And /config emits the same kebab keys, so the camelCase
    aliases (geminiApiKey, openAICompatibility, ...) are unreachable.
    
    Remove RAW_SECTION_ALIASES and inline the lookup; drop the raw-array
    and items/data fallbacks from extractArrayPayload.
  • refactor(providers): trim provider field allowlists to kebab-case
    The backend emits provider config with kebab-case JSON tags only
    (internal/config/config.go: ClaudeKey/CodexKey/GeminiKey/OpenAICompatibility,
    internal/api/handlers/management/config_auth_index.go for auth-index).
    The camelCase / snake_case entries in PROVIDER_KEY_FIELDS,
    OPENAI_PROVIDER_FIELDS, MODEL_ALIAS_FIELDS, API_KEY_ENTRY_FIELDS,
    CLOAK_FIELDS, RESPONSE_ONLY_FIELDS were dead — same for the identity
    helpers and the apiKeyEntries fallback in mergeOpenAIProviderPayload.
  • refactor(transformers): drop kebab/camel/snake aliases in /config parsing
    The backend always serializes config fields with kebab-case JSON tags
    (internal/config/config.go). The camelCase and snake_case fallbacks in
    normalizeApiKeyEntry / normalizeProviderKeyConfig / normalizeGeminiKeyConfig /
    normalizeOpenAIProvider / normalizeAmpcode* / normalizeConfigResponse are
    dead paths. Read kebab-case only.
    
    The legacy openai-compatibility `api-keys` (flat string array) flat-string
    branch is also gone — backend startup migration is disabled
    (internal/config/config.go:657).
  • Merge remote-tracking branch 'origin/pr/292' into dev
    # Conflicts:
    #	src/features/providers/sheets/forms/BaseProviderForm.tsx
  • fix(ui): address review feedback for API key toggle PR
    - Remove applyRawApiKey helper; populate apiKey directly in buildInitialForm
    - Revert useState hooks to single-line form
    - Replace duplicated .passwordInput styles with @extend .input
  • fix(ui): resolve connectivity status mismatch after prepend insertion
    Prepending new API key entries shifted all existing indices, causing useConnectivityTest index-based status tracking to desync.
    
    Fix: keep array operations as append (stable indices) and reverse the rendering order so new entries appear at the top visually. Use realIdx for status lookups, React keys, and all updateField operations.
  • fix(ui): add show/hide toggle for API key inputs and populate key on edit
    Add eye toggle button for all API key input fields so users can verify their keys before saving.
    
    Changes:
    
    - BaseProviderForm: wrap single API key field (Gemini/Codex/Claude/Vertex) with passwordField + toggle button
    
    - BaseProviderForm: wrap per-entry API key fields (OpenAI-compatible) with passwordField + toggle button
    
    - BaseProviderForm: extract applyRawApiKey helper to populate apiKey from resource.raw in edit mode
    
    - BaseProviderForm: sync initialFormSignature with applyRawApiKey to prevent false isDirty
    
    - sharedForm.module.scss: add .passwordField, .passwordInput, .passwordToggle styles
    
    - i18n: add showApiKey/hideApiKey keys to en, zh-CN, zh-TW, ru locale files
    
    - accessibility: add aria-label and title attributes on both toggle buttons
37 changed files with 947 additions and 727 deletions
+6 -11
View File
@@ -213,7 +213,6 @@ export function MainLayout() {
const logout = useAuthStore((state) => state.logout);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache);
@@ -431,16 +430,12 @@ export function MainLayout() {
metaKey: 'nav_meta.quota_management',
icon: sidebarIcons.quota,
},
...(config?.loggingToFile
? [
{
path: '/logs',
labelKey: 'nav.logs',
metaKey: 'nav_meta.logs',
icon: sidebarIcons.logs,
},
]
: []),
{
path: '/logs',
labelKey: 'nav.logs',
metaKey: 'nav_meta.logs',
icon: sidebarIcons.logs,
},
],
},
{
@@ -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,
+1 -1
View File
@@ -90,8 +90,8 @@ export interface ModelEntryInput {
export interface ApiKeyEntryInput {
apiKey: string;
existingApiKey?: string;
proxyUrl: string;
headersText: string;
authIndex?: string;
}
+14 -44
View File
@@ -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,
+13
View File
@@ -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",
+13
View File
@@ -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-ключ",
+13
View File
@@ -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 密钥",
+13
View File
@@ -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 金鑰",
+2
View File
@@ -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}
+32 -1
View File
@@ -13,11 +13,25 @@
}
}
.pageHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
margin: 0 0 $spacing-lg 0;
@include mobile {
align-items: flex-start;
flex-direction: column;
gap: $spacing-xs;
}
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-lg 0;
margin: 0;
}
.tabBar {
@@ -84,6 +98,17 @@
}
}
.runtimeNotice {
flex: 0 0 auto;
padding: 4px 10px;
border: 1px solid var(--border-color);
border-radius: $radius-full;
color: var(--text-secondary);
background: var(--bg-secondary);
font-size: 12px;
line-height: 1.3;
}
.toolbar {
display: flex;
align-items: center;
@@ -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
View File
@@ -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>
+29 -9
View File
@@ -10,6 +10,7 @@ const LOG_LATENCY_REGEX =
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
const LOG_NAMED_REQUEST_ID_REGEX = /\brequest[_-]?id=([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\b/i;
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
const GIN_TIMESTAMP_SEGMENT_REGEX =
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
@@ -87,6 +88,14 @@ const inferLogLevel = (line: string): LogLevel | undefined => {
return undefined;
};
const extractNamedRequestId = (text: string): string | undefined => {
const match = text.match(LOG_NAMED_REQUEST_ID_REGEX);
if (!match) return undefined;
const id = match[1];
if (/^-+$/.test(id)) return undefined;
return id;
};
const extractHttpMethodAndPath = (text: string): { method?: HttpMethod; path?: string } => {
const match = text.match(HTTP_METHOD_REGEX);
if (!match) return {};
@@ -163,16 +172,24 @@ export const parseLogLine = (raw: string): ParsedLogLine => {
}
}
// request id (8-char hex or dashes)
const requestIdIndex = segments.findIndex((segment) => LOG_REQUEST_ID_REGEX.test(segment));
// request id
const requestIdIndex = segments.findIndex(
(segment) => LOG_REQUEST_ID_REGEX.test(segment) || Boolean(extractNamedRequestId(segment))
);
if (requestIdIndex >= 0) {
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
if (match) {
const id = match[1];
if (!/^-+$/.test(id)) {
requestId = id;
}
const namedId = extractNamedRequestId(segments[requestIdIndex]);
if (namedId) {
requestId = namedId;
consumed.add(requestIdIndex);
} else {
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
if (match) {
const id = match[1];
if (!/^-+$/.test(id)) {
requestId = id;
}
consumed.add(requestIdIndex);
}
}
}
@@ -243,6 +260,10 @@ export const parseLogLine = (raw: string): ParsedLogLine => {
const parsed = extractHttpMethodAndPath(remaining);
method = parsed.method;
path = parsed.path;
if (!requestId) {
requestId = extractNamedRequestId(remaining);
}
}
if (!level) level = inferLogLevel(raw);
@@ -272,4 +293,3 @@ export const parseLogLine = (raw: string): ParsedLogLine => {
message,
};
};
+2 -2
View File
@@ -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 {
+15 -71
View File
@@ -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;
+16 -4
View File
@@ -7,10 +7,15 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import type { ApiClientConfig, ApiError } from '@/types';
import {
BUILD_DATE_HEADER_KEYS,
CPA_BUILD_DATE_HEADER_KEYS,
CPA_VERSION_HEADER_KEYS,
HOME_BUILD_DATE_HEADER_KEYS,
HOME_VERSION_HEADER_KEYS,
REQUEST_TIMEOUT_MS,
VERSION_HEADER_KEYS
} from '@/utils/constants';
import { computeApiUrl } from '@/utils/connection';
import type { ServerRuntimeKind } from '@/types';
class ApiClient {
private instance: AxiosInstance;
@@ -109,14 +114,21 @@ class ApiClient {
this.instance.interceptors.response.use(
(response) => {
const headers = response.headers as Record<string, string | undefined>;
const version = this.readHeader(headers, VERSION_HEADER_KEYS);
const buildDate = this.readHeader(headers, BUILD_DATE_HEADER_KEYS);
const homeVersion = this.readHeader(headers, HOME_VERSION_HEADER_KEYS);
const homeBuildDate = this.readHeader(headers, HOME_BUILD_DATE_HEADER_KEYS);
const cpaVersion = this.readHeader(headers, CPA_VERSION_HEADER_KEYS);
const cpaBuildDate = this.readHeader(headers, CPA_BUILD_DATE_HEADER_KEYS);
const version = homeVersion || cpaVersion || this.readHeader(headers, VERSION_HEADER_KEYS);
const buildDate =
homeBuildDate || cpaBuildDate || this.readHeader(headers, BUILD_DATE_HEADER_KEYS);
const runtimeKind: ServerRuntimeKind | null =
homeVersion || homeBuildDate ? 'home' : cpaVersion || cpaBuildDate ? 'cpa' : null;
// 触发版本更新事件(后续通过 store 处理)
if (version || buildDate) {
if (version || buildDate || runtimeKind) {
window.dispatchEvent(
new CustomEvent('server-version-update', {
detail: { version: version || null, buildDate: buildDate || null }
detail: { version: version || null, buildDate: buildDate || null, runtimeKind }
})
);
}
+3 -3
View File
@@ -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
View File
@@ -5,14 +5,48 @@
import { apiClient } from './client';
import { LOGS_TIMEOUT_MS } from '@/utils/constants';
export type LogCursor = number | string;
export type LogBackendKind = 'unknown' | 'file' | 'home-db';
export interface LogsQuery {
after?: number;
after?: LogCursor;
limit?: number;
offset?: number;
}
export interface CPALogsResponse {
lines: string[];
'line-count': number;
'latest-timestamp': number;
}
export interface HomeLogRecord {
id?: number;
timestamp?: string | number;
client_ip?: string;
request_id?: string;
home_ip?: string;
level?: string;
line?: string;
created_at?: string | number;
}
export interface HomeLogsResponse {
logs?: HomeLogRecord[];
total?: number;
limit?: number;
offset?: number;
}
export interface LogsResponse {
lines: string[];
'line-count': number;
'latest-timestamp': number;
lineCount: number;
latestCursor?: LogCursor;
logBackendKind: LogBackendKind;
requestLogHomeIpById?: Record<string, string>;
total?: number;
limit?: number;
offset?: number;
}
export interface ErrorLogFile {
@@ -25,9 +59,99 @@ export interface ErrorLogsResponse {
files?: ErrorLogFile[];
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object';
const stringValue = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
const unixSecondsFromValue = (value: unknown): number => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
const text = stringValue(value);
if (!text) return 0;
const asNumber = Number(text);
if (Number.isFinite(asNumber)) return asNumber;
const asDate = Date.parse(text);
return Number.isFinite(asDate) ? Math.floor(asDate / 1000) : 0;
};
const homeCursorFromRecord = (record: HomeLogRecord): string => {
const timestamp = stringValue(record.timestamp);
if (timestamp) return timestamp;
const createdAt = stringValue(record.created_at);
return createdAt;
};
const normalizeCPALogs = (data: Record<string, unknown>): LogsResponse => {
const lines = Array.isArray(data.lines)
? data.lines.filter((line): line is string => typeof line === 'string')
: [];
const latestTimestamp = unixSecondsFromValue(data['latest-timestamp']);
const lineCount = Number(data['line-count']);
return {
lines,
lineCount: Number.isFinite(lineCount) ? lineCount : lines.length,
latestCursor: latestTimestamp > 0 ? latestTimestamp : undefined,
logBackendKind: 'file'
};
};
const normalizeHomeLogs = (data: Record<string, unknown>): LogsResponse => {
const rawLogs = Array.isArray(data.logs)
? data.logs.filter((entry): entry is HomeLogRecord => isRecord(entry))
: [];
const orderedLogs = [...rawLogs].reverse();
const lines = orderedLogs
.map((record) => record.line)
.filter((line): line is string => typeof line === 'string' && line.length > 0);
const requestLogHomeIpById = orderedLogs.reduce<Record<string, string>>((acc, record) => {
const requestId = stringValue(record.request_id);
const homeIp = stringValue(record.home_ip);
if (requestId && homeIp) {
acc[requestId] = homeIp;
}
return acc;
}, {});
const latestCursor = rawLogs.reduce<string | undefined>((latest, record) => {
const cursor = homeCursorFromRecord(record);
if (!cursor) return latest;
if (!latest) return cursor;
const latestTime = Date.parse(latest);
const cursorTime = Date.parse(cursor);
if (!Number.isFinite(latestTime) || !Number.isFinite(cursorTime)) return latest;
return cursorTime > latestTime ? cursor : latest;
}, undefined);
const total = Number(data.total);
const limit = Number(data.limit);
const offset = Number(data.offset);
return {
lines,
lineCount: Number.isFinite(total) ? total : lines.length,
latestCursor,
logBackendKind: 'home-db',
requestLogHomeIpById,
total: Number.isFinite(total) ? total : undefined,
limit: Number.isFinite(limit) ? limit : undefined,
offset: Number.isFinite(offset) ? offset : undefined
};
};
const normalizeLogsResponse = (data: unknown): LogsResponse => {
if (!isRecord(data)) {
return { lines: [], lineCount: 0, logBackendKind: 'unknown' };
}
if (Array.isArray(data.logs)) return normalizeHomeLogs(data);
if (Array.isArray(data.lines)) return normalizeCPALogs(data);
return { lines: [], lineCount: 0, logBackendKind: 'unknown' };
};
export const logsApi = {
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> =>
apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS }),
async fetchLogs(params: LogsQuery = {}): Promise<LogsResponse> {
const data = await apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS });
return normalizeLogsResponse(data);
},
clearLogs: () => apiClient.delete('/logs'),
@@ -40,8 +164,9 @@ export const logsApi = {
timeout: LOGS_TIMEOUT_MS
}),
downloadRequestLogById: (id: string) =>
downloadRequestLogById: (id: string, homeIp?: string) =>
apiClient.getRaw(`/request-log-by-id/${encodeURIComponent(id)}`, {
params: homeIp ? { home_ip: homeIp } : undefined,
responseType: 'blob',
timeout: LOGS_TIMEOUT_MS
}),
+15 -75
View File
@@ -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;
};
+68 -116
View File
@@ -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;
}
+18 -1
View File
@@ -3,7 +3,24 @@
*/
import { apiClient } from './client';
import type { ServerRuntimeKind } from '@/types';
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object';
export const versionApi = {
checkLatest: () => apiClient.get<Record<string, unknown>>('/latest-version')
checkLatest: () => apiClient.get<Record<string, unknown>>('/latest-version'),
async detectRuntimeKind(): Promise<ServerRuntimeKind> {
try {
const data = await apiClient.get('/nodes');
return isRecord(data) && Array.isArray(data.nodes) ? 'home' : 'unknown';
} catch (error: unknown) {
const status = isRecord(error) ? error.status : undefined;
if (status === 404 || status === 405) {
return 'cpa';
}
return 'unknown';
}
}
};
+50 -9
View File
@@ -5,10 +5,11 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { AuthState, LoginCredentials, ConnectionStatus } from '@/types';
import type { AuthState, LoginCredentials, ConnectionStatus, ServerRuntimeKind } from '@/types';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
import { obfuscatedStorage } from '@/services/storage/secureStorage';
import { apiClient } from '@/services/api/client';
import { versionApi } from '@/services/api/version';
import { useConfigStore } from './useConfigStore';
import { useModelsStore } from './useModelsStore';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
@@ -22,12 +23,26 @@ interface AuthStoreState extends AuthState {
logout: () => void;
checkAuth: () => Promise<boolean>;
restoreSession: () => Promise<boolean>;
updateServerVersion: (version: string | null, buildDate?: string | null) => void;
updateServerVersion: (
version: string | null,
buildDate?: string | null,
runtimeKind?: ServerRuntimeKind | null
) => void;
updateServerRuntimeKind: (runtimeKind: ServerRuntimeKind) => void;
updateConnectionStatus: (status: ConnectionStatus, error?: string | null) => void;
}
let restoreSessionPromise: Promise<boolean> | null = null;
const detectRuntimeKind = async (): Promise<ServerRuntimeKind> => {
try {
return await versionApi.detectRuntimeKind();
} catch (error) {
console.warn('Runtime kind detection failed:', error);
return 'unknown';
}
};
export const useAuthStore = create<AuthStoreState>()(
persist(
(set, get) => ({
@@ -38,6 +53,7 @@ export const useAuthStore = create<AuthStoreState>()(
rememberPassword: false,
serverVersion: null,
serverBuildDate: null,
serverRuntimeKind: 'unknown',
connectionStatus: 'disconnected',
connectionError: null,
@@ -93,7 +109,12 @@ export const useAuthStore = create<AuthStoreState>()(
const rememberPassword = credentials.rememberPassword ?? get().rememberPassword ?? false;
try {
set({ connectionStatus: 'connecting' });
set({
connectionStatus: 'connecting',
serverVersion: null,
serverBuildDate: null,
serverRuntimeKind: 'unknown'
});
useModelsStore.getState().clearCache();
// 配置 API 客户端
@@ -104,6 +125,7 @@ export const useAuthStore = create<AuthStoreState>()(
// 测试连接 - 获取配置
await useConfigStore.getState().fetchConfig(undefined, true);
const runtimeKind = await detectRuntimeKind();
// 登录成功
set({
@@ -112,7 +134,8 @@ export const useAuthStore = create<AuthStoreState>()(
managementKey,
rememberPassword,
connectionStatus: 'connected',
connectionError: null
connectionError: null,
...(runtimeKind !== 'unknown' ? { serverRuntimeKind: runtimeKind } : {})
});
if (rememberPassword) {
localStorage.setItem('isLoggedIn', 'true');
@@ -145,6 +168,7 @@ export const useAuthStore = create<AuthStoreState>()(
managementKey: '',
serverVersion: null,
serverBuildDate: null,
serverRuntimeKind: 'unknown',
connectionStatus: 'disconnected',
connectionError: null
});
@@ -165,10 +189,12 @@ export const useAuthStore = create<AuthStoreState>()(
// 验证连接
await useConfigStore.getState().fetchConfig();
const runtimeKind = await detectRuntimeKind();
set({
isAuthenticated: true,
connectionStatus: 'connected'
connectionStatus: 'connected',
...(runtimeKind !== 'unknown' ? { serverRuntimeKind: runtimeKind } : {})
});
return true;
@@ -182,8 +208,16 @@ export const useAuthStore = create<AuthStoreState>()(
},
// 更新服务器版本
updateServerVersion: (version, buildDate) => {
set({ serverVersion: version || null, serverBuildDate: buildDate || null });
updateServerVersion: (version, buildDate, runtimeKind) => {
set((state) => ({
serverVersion: version || null,
serverBuildDate: buildDate || null,
serverRuntimeKind: runtimeKind || state.serverRuntimeKind
}));
},
updateServerRuntimeKind: (runtimeKind) => {
set({ serverRuntimeKind: runtimeKind });
},
// 更新连接状态
@@ -213,7 +247,8 @@ export const useAuthStore = create<AuthStoreState>()(
...(state.rememberPassword ? { managementKey: state.managementKey } : {}),
rememberPassword: state.rememberPassword,
serverVersion: state.serverVersion,
serverBuildDate: state.serverBuildDate
serverBuildDate: state.serverBuildDate,
serverRuntimeKind: state.serverRuntimeKind
})
}
)
@@ -229,7 +264,13 @@ if (typeof window !== 'undefined') {
'server-version-update',
((e: CustomEvent) => {
const detail = e.detail || {};
useAuthStore.getState().updateServerVersion(detail.version || null, detail.buildDate || null);
const runtimeKind =
detail.runtimeKind === 'cpa' || detail.runtimeKind === 'home'
? detail.runtimeKind
: null;
useAuthStore
.getState()
.updateServerVersion(detail.version || null, detail.buildDate || null, runtimeKind);
}) as EventListener
);
}
+2
View File
@@ -18,10 +18,12 @@ export interface AuthState {
rememberPassword: boolean;
serverVersion: string | null;
serverBuildDate: string | null;
serverRuntimeKind: ServerRuntimeKind;
}
// 连接状态
export type ConnectionStatus = 'connected' | 'disconnected' | 'connecting' | 'error';
export type ServerRuntimeKind = 'unknown' | 'cpa' | 'home';
export interface ConnectionInfo {
status: ConnectionStatus;
-1
View File
@@ -13,7 +13,6 @@ export interface ModelAlias {
export interface ApiKeyEntry {
apiKey: string;
proxyUrl?: string;
headers?: Record<string, string>;
authIndex?: string;
}
+14 -2
View File
@@ -16,8 +16,20 @@ export const CACHE_EXPIRY_MS = 30 * 1000; // 与基线保持一致,减少管
export const DEFAULT_API_PORT = 8317;
export const MANAGEMENT_API_PREFIX = '/v0/management';
export const REQUEST_TIMEOUT_MS = 30 * 1000;
export const VERSION_HEADER_KEYS = ['x-cpa-version', 'x-server-version'];
export const BUILD_DATE_HEADER_KEYS = ['x-cpa-build-date', 'x-server-build-date'];
export const CPA_VERSION_HEADER_KEYS = ['x-cpa-version'];
export const CPA_BUILD_DATE_HEADER_KEYS = ['x-cpa-build-date'];
export const HOME_VERSION_HEADER_KEYS = ['x-cpa-home-version'];
export const HOME_BUILD_DATE_HEADER_KEYS = ['x-cpa-home-build-date'];
export const VERSION_HEADER_KEYS = [
...HOME_VERSION_HEADER_KEYS,
...CPA_VERSION_HEADER_KEYS,
'x-server-version'
];
export const BUILD_DATE_HEADER_KEYS = [
...HOME_BUILD_DATE_HEADER_KEYS,
...CPA_BUILD_DATE_HEADER_KEYS,
'x-server-build-date'
];
// 日志相关
export const LOGS_TIMEOUT_MS = 60 * 1000;