mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 02:30:51 +08:00
249 lines
9.1 KiB
TypeScript
249 lines
9.1 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useNavigate, useOutletContext } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Card } from '@/components/ui/Card';
|
|
import { Input } from '@/components/ui/Input';
|
|
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
|
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
|
import { modelsApi } from '@/services/api';
|
|
import type { ModelInfo } from '@/utils/models';
|
|
import { buildHeaderObject } from '@/utils/headers';
|
|
import type { ClaudeEditOutletContext } from './AiProvidersClaudeEditLayout';
|
|
import styles from './AiProvidersPage.module.scss';
|
|
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
|
|
|
const getErrorMessage = (err: unknown) => {
|
|
if (err instanceof Error) return err.message;
|
|
if (typeof err === 'string') return err;
|
|
return '';
|
|
};
|
|
|
|
export function AiProvidersClaudeModelsPage() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const {
|
|
disableControls,
|
|
loading: initialLoading,
|
|
saving,
|
|
form,
|
|
mergeDiscoveredModels,
|
|
} = useOutletContext<ClaudeEditOutletContext>();
|
|
|
|
const [endpoint, setEndpoint] = useState('');
|
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
const [fetching, setFetching] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [search, setSearch] = useState('');
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const autoFetchSignatureRef = useRef<string>('');
|
|
|
|
const filteredModels = useMemo(() => {
|
|
const filter = search.trim().toLowerCase();
|
|
if (!filter) return models;
|
|
return models.filter((model) => {
|
|
const name = (model.name || '').toLowerCase();
|
|
const alias = (model.alias || '').toLowerCase();
|
|
const desc = (model.description || '').toLowerCase();
|
|
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
|
});
|
|
}, [models, search]);
|
|
|
|
const fetchClaudeModelDiscovery = useCallback(async () => {
|
|
setFetching(true);
|
|
setError('');
|
|
const headerObject = buildHeaderObject(form.headers);
|
|
try {
|
|
const list = await modelsApi.fetchClaudeModelsViaApiCall(
|
|
form.baseUrl ?? '',
|
|
form.apiKey.trim() || undefined,
|
|
headerObject
|
|
);
|
|
setModels(list);
|
|
} catch (err: unknown) {
|
|
setModels([]);
|
|
const message = getErrorMessage(err);
|
|
const hasCustomXApiKey = Object.keys(headerObject).some(
|
|
(key) => key.toLowerCase() === 'x-api-key'
|
|
);
|
|
const hasAuthorization = Object.keys(headerObject).some(
|
|
(key) => key.toLowerCase() === 'authorization'
|
|
);
|
|
const shouldAttachDiag =
|
|
message.toLowerCase().includes('x-api-key') || message.includes('401');
|
|
const diag = shouldAttachDiag
|
|
? ` [diag: apiKeyField=${form.apiKey.trim() ? 'yes' : 'no'}, customXApiKey=${
|
|
hasCustomXApiKey ? 'yes' : 'no'
|
|
}, customAuthorization=${hasAuthorization ? 'yes' : 'no'}]`
|
|
: '';
|
|
setError(`${t('ai_providers.claude_models_fetch_error')}: ${message}${diag}`);
|
|
} finally {
|
|
setFetching(false);
|
|
}
|
|
}, [form.apiKey, form.baseUrl, form.headers, t]);
|
|
|
|
useEffect(() => {
|
|
if (initialLoading) return;
|
|
|
|
const nextEndpoint = modelsApi.buildClaudeModelsEndpoint(form.baseUrl ?? '');
|
|
setEndpoint(nextEndpoint);
|
|
setModels([]);
|
|
setSearch('');
|
|
setSelected(new Set());
|
|
setError('');
|
|
|
|
const headerObject = buildHeaderObject(form.headers);
|
|
const hasCustomXApiKey = Object.keys(headerObject).some(
|
|
(key) => key.toLowerCase() === 'x-api-key'
|
|
);
|
|
const hasAuthorization = Object.keys(headerObject).some(
|
|
(key) => key.toLowerCase() === 'authorization'
|
|
);
|
|
const hasApiKeyField = Boolean(form.apiKey.trim());
|
|
const canAutoFetch = hasApiKeyField || hasCustomXApiKey || hasAuthorization;
|
|
|
|
// Avoid firing a guaranteed 401 on initial render (common while the parent form is still
|
|
// initializing), and avoid duplicate auto-fetches (e.g. React StrictMode in dev).
|
|
if (!canAutoFetch) return;
|
|
|
|
const headerSignature = Object.entries(headerObject)
|
|
.sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase()))
|
|
.map(([key, value]) => `${key}:${value}`)
|
|
.join('|');
|
|
const signature = `${nextEndpoint}||${form.apiKey.trim()}||${headerSignature}`;
|
|
if (autoFetchSignatureRef.current === signature) return;
|
|
autoFetchSignatureRef.current = signature;
|
|
|
|
void fetchClaudeModelDiscovery();
|
|
}, [fetchClaudeModelDiscovery, form.apiKey, form.baseUrl, form.headers, initialLoading]);
|
|
|
|
const handleBack = useCallback(() => {
|
|
navigate(-1);
|
|
}, [navigate]);
|
|
|
|
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
handleBack();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [handleBack]);
|
|
|
|
const toggleSelection = (name: string) => {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(name)) {
|
|
next.delete(name);
|
|
} else {
|
|
next.add(name);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleApply = () => {
|
|
const selectedModels = models.filter((model) => selected.has(model.name));
|
|
if (selectedModels.length) {
|
|
mergeDiscoveredModels(selectedModels);
|
|
}
|
|
handleBack();
|
|
};
|
|
|
|
const canApply = !disableControls && !saving && !fetching;
|
|
|
|
return (
|
|
<SecondaryScreenShell
|
|
ref={swipeRef}
|
|
contentClassName={layoutStyles.content}
|
|
title={t('ai_providers.claude_models_fetch_title')}
|
|
onBack={handleBack}
|
|
backLabel={t('common.back')}
|
|
backAriaLabel={t('common.back')}
|
|
rightAction={
|
|
<Button size="sm" onClick={handleApply} disabled={!canApply}>
|
|
{t('ai_providers.claude_models_fetch_apply')}
|
|
</Button>
|
|
}
|
|
isLoading={initialLoading}
|
|
loadingLabel={t('common.loading')}
|
|
>
|
|
<Card>
|
|
<div className={styles.openaiModelsContent}>
|
|
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_hint')}</div>
|
|
<div className={styles.openaiModelsEndpointSection}>
|
|
<label className={styles.openaiModelsEndpointLabel}>
|
|
{t('ai_providers.claude_models_fetch_url_label')}
|
|
</label>
|
|
<div className={styles.openaiModelsEndpointControls}>
|
|
<input
|
|
className={`input ${styles.openaiModelsEndpointInput}`}
|
|
readOnly
|
|
value={endpoint}
|
|
/>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => void fetchClaudeModelDiscovery()}
|
|
loading={fetching}
|
|
disabled={disableControls || saving}
|
|
>
|
|
{t('ai_providers.claude_models_fetch_refresh')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<Input
|
|
label={t('ai_providers.claude_models_search_label')}
|
|
placeholder={t('ai_providers.claude_models_search_placeholder')}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
disabled={fetching}
|
|
/>
|
|
{error && <div className="error-box">{error}</div>}
|
|
{fetching ? (
|
|
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_loading')}</div>
|
|
) : models.length === 0 ? (
|
|
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_empty')}</div>
|
|
) : filteredModels.length === 0 ? (
|
|
<div className={styles.sectionHint}>{t('ai_providers.claude_models_search_empty')}</div>
|
|
) : (
|
|
<div className={styles.modelDiscoveryList}>
|
|
{filteredModels.map((model) => {
|
|
const checked = selected.has(model.name);
|
|
return (
|
|
<label
|
|
key={model.name}
|
|
className={`${styles.modelDiscoveryRow} ${
|
|
checked ? styles.modelDiscoveryRowSelected : ''
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={() => toggleSelection(model.name)}
|
|
/>
|
|
<div className={styles.modelDiscoveryMeta}>
|
|
<div className={styles.modelDiscoveryName}>
|
|
{model.name}
|
|
{model.alias && (
|
|
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
|
)}
|
|
</div>
|
|
{model.description && (
|
|
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
|
)}
|
|
</div>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</SecondaryScreenShell>
|
|
);
|
|
}
|