mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 11:10:49 +08:00
refactor(providers): remove deprecated AI provider modal implementations and unused modal types
This commit is contained in:
@@ -1,281 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
|
||||||
import { useConfigStore, useNotificationStore } from '@/stores';
|
|
||||||
import { ampcodeApi } from '@/services/api';
|
|
||||||
import type { AmpcodeConfig } from '@/types';
|
|
||||||
import { maskApiKey } from '@/utils/format';
|
|
||||||
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '../utils';
|
|
||||||
import type { AmpcodeFormState } from '../types';
|
|
||||||
|
|
||||||
interface AmpcodeModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
disableControls: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onBusyChange?: (busy: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { showNotification, showConfirmation } = useNotificationStore();
|
|
||||||
const config = useConfigStore((state) => state.config);
|
|
||||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
|
||||||
const clearCache = useConfigStore((state) => state.clearCache);
|
|
||||||
|
|
||||||
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const [mappingsDirty, setMappingsDirty] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const initializedRef = useRef(false);
|
|
||||||
|
|
||||||
const getErrorMessage = (err: unknown) => {
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
if (typeof err === 'string') return err;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onBusyChange?.(loading || saving);
|
|
||||||
}, [loading, saving, onBusyChange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
initializedRef.current = false;
|
|
||||||
setLoading(false);
|
|
||||||
setSaving(false);
|
|
||||||
setError('');
|
|
||||||
setLoaded(false);
|
|
||||||
setMappingsDirty(false);
|
|
||||||
setForm(buildAmpcodeFormState(null));
|
|
||||||
onBusyChange?.(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (initializedRef.current) return;
|
|
||||||
initializedRef.current = true;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setLoaded(false);
|
|
||||||
setMappingsDirty(false);
|
|
||||||
setError('');
|
|
||||||
setForm(buildAmpcodeFormState(config?.ampcode ?? null));
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const ampcode = await ampcodeApi.getAmpcode();
|
|
||||||
setLoaded(true);
|
|
||||||
updateConfigValue('ampcode', ampcode);
|
|
||||||
clearCache('ampcode');
|
|
||||||
setForm(buildAmpcodeFormState(ampcode));
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(getErrorMessage(err) || t('notification.refresh_failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
|
|
||||||
|
|
||||||
const clearAmpcodeUpstreamApiKey = async () => {
|
|
||||||
showConfirmation({
|
|
||||||
title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
|
|
||||||
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
|
|
||||||
variant: 'danger',
|
|
||||||
confirmText: t('common.confirm'),
|
|
||||||
onConfirm: async () => {
|
|
||||||
setSaving(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await ampcodeApi.clearUpstreamApiKey();
|
|
||||||
const previous = config?.ampcode ?? {};
|
|
||||||
const next: AmpcodeConfig = { ...previous };
|
|
||||||
delete next.upstreamApiKey;
|
|
||||||
updateConfigValue('ampcode', next);
|
|
||||||
clearCache('ampcode');
|
|
||||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
setError(message);
|
|
||||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const performSaveAmpcode = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const upstreamUrl = form.upstreamUrl.trim();
|
|
||||||
const overrideKey = form.upstreamApiKey.trim();
|
|
||||||
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
|
|
||||||
|
|
||||||
if (upstreamUrl) {
|
|
||||||
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
|
|
||||||
} else {
|
|
||||||
await ampcodeApi.clearUpstreamUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
|
|
||||||
|
|
||||||
if (loaded || mappingsDirty) {
|
|
||||||
if (modelMappings.length) {
|
|
||||||
await ampcodeApi.saveModelMappings(modelMappings);
|
|
||||||
} else {
|
|
||||||
await ampcodeApi.clearModelMappings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overrideKey) {
|
|
||||||
await ampcodeApi.updateUpstreamApiKey(overrideKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
const previous = config?.ampcode ?? {};
|
|
||||||
const next: AmpcodeConfig = {
|
|
||||||
upstreamUrl: upstreamUrl || undefined,
|
|
||||||
forceModelMappings: form.forceModelMappings,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (previous.upstreamApiKey) {
|
|
||||||
next.upstreamApiKey = previous.upstreamApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(previous.modelMappings)) {
|
|
||||||
next.modelMappings = previous.modelMappings;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overrideKey) {
|
|
||||||
next.upstreamApiKey = overrideKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loaded || mappingsDirty) {
|
|
||||||
if (modelMappings.length) {
|
|
||||||
next.modelMappings = modelMappings;
|
|
||||||
} else {
|
|
||||||
delete next.modelMappings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateConfigValue('ampcode', next);
|
|
||||||
clearCache('ampcode');
|
|
||||||
showNotification(t('notification.ampcode_updated'), 'success');
|
|
||||||
onClose();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
setError(message);
|
|
||||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveAmpcode = async () => {
|
|
||||||
if (!loaded && mappingsDirty) {
|
|
||||||
showConfirmation({
|
|
||||||
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
|
|
||||||
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
|
|
||||||
variant: 'secondary', // Not dangerous, just a warning
|
|
||||||
confirmText: t('common.confirm'),
|
|
||||||
onConfirm: performSaveAmpcode,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await performSaveAmpcode();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t('ai_providers.ampcode_modal_title')}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={saving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={saveAmpcode} loading={saving} disabled={disableControls || loading}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.ampcode_upstream_url_label')}
|
|
||||||
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
|
|
||||||
value={form.upstreamUrl}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
|
|
||||||
disabled={loading || saving}
|
|
||||||
hint={t('ai_providers.ampcode_upstream_url_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.ampcode_upstream_api_key_label')}
|
|
||||||
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
|
|
||||||
type="password"
|
|
||||||
value={form.upstreamApiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
|
|
||||||
disabled={loading || saving}
|
|
||||||
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 8,
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: -8,
|
|
||||||
marginBottom: 12,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="hint" style={{ margin: 0 }}>
|
|
||||||
{t('ai_providers.ampcode_upstream_api_key_current', {
|
|
||||||
key: config?.ampcode?.upstreamApiKey
|
|
||||||
? maskApiKey(config.ampcode.upstreamApiKey)
|
|
||||||
: t('common.not_set'),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={clearAmpcodeUpstreamApiKey}
|
|
||||||
disabled={loading || saving || !config?.ampcode?.upstreamApiKey}
|
|
||||||
>
|
|
||||||
{t('ai_providers.ampcode_clear_upstream_api_key')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<ToggleSwitch
|
|
||||||
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
|
||||||
checked={form.forceModelMappings}
|
|
||||||
onChange={(value) => setForm((prev) => ({ ...prev, forceModelMappings: value }))}
|
|
||||||
disabled={loading || saving}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
|
|
||||||
<ModelInputList
|
|
||||||
entries={form.mappingEntries}
|
|
||||||
onChange={(entries) => {
|
|
||||||
setMappingsDirty(true);
|
|
||||||
setForm((prev) => ({ ...prev, mappingEntries: entries }));
|
|
||||||
}}
|
|
||||||
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
|
||||||
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
|
|
||||||
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
|
|
||||||
disabled={loading || saving}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
|
||||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
|
||||||
import { headersToEntries } from '@/utils/headers';
|
|
||||||
import { excludedModelsToText } from '../utils';
|
|
||||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
|
||||||
|
|
||||||
interface ClaudeModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): ProviderFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
proxyUrl: '',
|
|
||||||
headers: [],
|
|
||||||
models: [],
|
|
||||||
excludedModels: [],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
excludedText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ClaudeModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: ClaudeModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
modelEntries: modelsToEntries(initialData.models),
|
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.claude_edit_modal_title')
|
|
||||||
: t('ai_providers.claude_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.claude_add_modal_key_label')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.claude_add_modal_url_label')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.claude_add_modal_proxy_label')}
|
|
||||||
value={form.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.claude_models_label')}</label>
|
|
||||||
<ModelInputList
|
|
||||||
entries={form.modelEntries}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
|
||||||
addLabel={t('ai_providers.claude_models_add_btn')}
|
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
|
||||||
disabled={isSaving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
|
||||||
<textarea
|
|
||||||
className="input"
|
|
||||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
|
||||||
value={form.excludedText}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
|
||||||
import { headersToEntries } from '@/utils/headers';
|
|
||||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
|
||||||
import { excludedModelsToText } from '../utils';
|
|
||||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
|
||||||
|
|
||||||
interface CodexModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): ProviderFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
proxyUrl: '',
|
|
||||||
headers: [],
|
|
||||||
models: [],
|
|
||||||
excludedModels: [],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
excludedText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function CodexModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: CodexModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
modelEntries: modelsToEntries(initialData.models),
|
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.codex_edit_modal_title')
|
|
||||||
: t('ai_providers.codex_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.codex_add_modal_key_label')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.codex_add_modal_url_label')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.codex_add_modal_proxy_label')}
|
|
||||||
value={form.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
|
||||||
<textarea
|
|
||||||
className="input"
|
|
||||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
|
||||||
value={form.excludedText}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import type { GeminiKeyConfig } from '@/types';
|
|
||||||
import { headersToEntries } from '@/utils/headers';
|
|
||||||
import { excludedModelsToText } from '../utils';
|
|
||||||
import type { GeminiFormState, ProviderModalProps } from '../types';
|
|
||||||
|
|
||||||
interface GeminiModalProps extends ProviderModalProps<GeminiKeyConfig, GeminiFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): GeminiFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
headers: [],
|
|
||||||
excludedModels: [],
|
|
||||||
excludedText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function GeminiModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: GeminiModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<GeminiFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
void onSave(form, editIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.gemini_edit_modal_title')
|
|
||||||
: t('ai_providers.gemini_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.gemini_add_modal_key_label')}
|
|
||||||
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.gemini_base_url_label')}
|
|
||||||
placeholder={t('ai_providers.gemini_base_url_placeholder')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
|
||||||
<textarea
|
|
||||||
className="input"
|
|
||||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
|
||||||
value={form.excludedText}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { modelsApi } from '@/services/api';
|
|
||||||
import type { ApiKeyEntry } from '@/types';
|
|
||||||
import type { ModelInfo } from '@/utils/models';
|
|
||||||
import { buildHeaderObject, type HeaderEntry } from '@/utils/headers';
|
|
||||||
import { buildOpenAIModelsEndpoint } from '../utils';
|
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
|
||||||
|
|
||||||
interface OpenAIDiscoveryModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
baseUrl: string;
|
|
||||||
headers: HeaderEntry[];
|
|
||||||
apiKeyEntries: ApiKeyEntry[];
|
|
||||||
onClose: () => void;
|
|
||||||
onApply: (selected: ModelInfo[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpenAIDiscoveryModal({
|
|
||||||
isOpen,
|
|
||||||
baseUrl,
|
|
||||||
headers,
|
|
||||||
apiKeyEntries,
|
|
||||||
onClose,
|
|
||||||
onApply,
|
|
||||||
}: OpenAIDiscoveryModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [endpoint, setEndpoint] = useState('');
|
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const getErrorMessage = (err: unknown) => {
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
if (typeof err === 'string') return err;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
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 fetchOpenaiModelDiscovery = useCallback(
|
|
||||||
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
|
|
||||||
const trimmedBaseUrl = baseUrl.trim();
|
|
||||||
if (!trimmedBaseUrl) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const headerObject = buildHeaderObject(headers);
|
|
||||||
const firstKey = apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
|
|
||||||
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
|
|
||||||
const list = await modelsApi.fetchModelsViaApiCall(
|
|
||||||
trimmedBaseUrl,
|
|
||||||
hasAuthHeader ? undefined : firstKey,
|
|
||||||
headerObject
|
|
||||||
);
|
|
||||||
setModels(list);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (allowFallback) {
|
|
||||||
try {
|
|
||||||
const list = await modelsApi.fetchModelsViaApiCall(trimmedBaseUrl);
|
|
||||||
setModels(list);
|
|
||||||
return;
|
|
||||||
} catch (fallbackErr: unknown) {
|
|
||||||
const message = getErrorMessage(fallbackErr) || getErrorMessage(err);
|
|
||||||
setModels([]);
|
|
||||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setModels([]);
|
|
||||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[apiKeyEntries, baseUrl, headers, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
setEndpoint(buildOpenAIModelsEndpoint(baseUrl));
|
|
||||||
setModels([]);
|
|
||||||
setSearch('');
|
|
||||||
setSelected(new Set());
|
|
||||||
setError('');
|
|
||||||
void fetchOpenaiModelDiscovery();
|
|
||||||
}, [baseUrl, fetchOpenaiModelDiscovery, isOpen]);
|
|
||||||
|
|
||||||
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));
|
|
||||||
onApply(selectedModels);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t('ai_providers.openai_models_fetch_title')}
|
|
||||||
width={720}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
|
||||||
{t('ai_providers.openai_models_fetch_back')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleApply} disabled={loading}>
|
|
||||||
{t('ai_providers.openai_models_fetch_apply')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="hint" style={{ marginBottom: 8 }}>
|
|
||||||
{t('ai_providers.openai_models_fetch_hint')}
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<input className="input" readOnly value={endpoint} />
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
{t('ai_providers.openai_models_fetch_refresh')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.openai_models_search_label')}
|
|
||||||
placeholder={t('ai_providers.openai_models_search_placeholder')}
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
|
||||||
{loading ? (
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
|
|
||||||
) : models.length === 0 ? (
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
|
|
||||||
) : filteredModels.length === 0 ? (
|
|
||||||
<div className="hint">{t('ai_providers.openai_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>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
|
||||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
|
||||||
import { useNotificationStore } from '@/stores';
|
|
||||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
|
||||||
import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types';
|
|
||||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
|
||||||
import type { ModelInfo } from '@/utils/models';
|
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
|
||||||
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '../utils';
|
|
||||||
import type { ModelEntry, OpenAIFormState, ProviderModalProps } from '../types';
|
|
||||||
import { OpenAIDiscoveryModal } from './OpenAIDiscoveryModal';
|
|
||||||
|
|
||||||
const OPENAI_TEST_TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
interface OpenAIModalProps extends ProviderModalProps<OpenAIProviderConfig, OpenAIFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): OpenAIFormState => ({
|
|
||||||
name: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
headers: [],
|
|
||||||
apiKeyEntries: [buildApiKeyEntry()],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
testModel: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function OpenAIModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: OpenAIModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { showNotification } = useNotificationStore();
|
|
||||||
const [form, setForm] = useState<OpenAIFormState>(buildEmptyForm);
|
|
||||||
const [discoveryOpen, setDiscoveryOpen] = useState(false);
|
|
||||||
const [testModel, setTestModel] = useState('');
|
|
||||||
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
|
||||||
const [testMessage, setTestMessage] = useState('');
|
|
||||||
|
|
||||||
const getErrorMessage = (err: unknown) => {
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
if (typeof err === 'string') return err;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const availableModels = useMemo(
|
|
||||||
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
|
|
||||||
[form.modelEntries]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialData) {
|
|
||||||
const modelEntries = modelsToEntries(initialData.models);
|
|
||||||
setForm({
|
|
||||||
name: initialData.name,
|
|
||||||
prefix: initialData.prefix ?? '',
|
|
||||||
baseUrl: initialData.baseUrl,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
testModel: initialData.testModel,
|
|
||||||
modelEntries,
|
|
||||||
apiKeyEntries: initialData.apiKeyEntries?.length
|
|
||||||
? initialData.apiKeyEntries
|
|
||||||
: [buildApiKeyEntry()],
|
|
||||||
});
|
|
||||||
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
|
|
||||||
const initialModel =
|
|
||||||
initialData.testModel && available.includes(initialData.testModel)
|
|
||||||
? initialData.testModel
|
|
||||||
: available[0] || '';
|
|
||||||
setTestModel(initialModel);
|
|
||||||
} else {
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
setTestModel('');
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (availableModels.length === 0) {
|
|
||||||
if (testModel) {
|
|
||||||
setTestModel('');
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!testModel || !availableModels.includes(testModel)) {
|
|
||||||
setTestModel(availableModels[0]);
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
}
|
|
||||||
}, [availableModels, isOpen, testModel]);
|
|
||||||
|
|
||||||
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
|
|
||||||
const list = entries.length ? entries : [buildApiKeyEntry()];
|
|
||||||
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
|
|
||||||
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
|
|
||||||
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeEntry = (idx: number) => {
|
|
||||||
const next = list.filter((_, i) => i !== idx);
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addEntry = () => {
|
|
||||||
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="stack">
|
|
||||||
{list.map((entry, index) => (
|
|
||||||
<div key={index} className="item-row">
|
|
||||||
<div className="item-meta">
|
|
||||||
<Input
|
|
||||||
label={`${t('common.api_key')} #${index + 1}`}
|
|
||||||
value={entry.apiKey}
|
|
||||||
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('common.proxy_url')}
|
|
||||||
value={entry.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="item-actions">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeEntry(index)}
|
|
||||||
disabled={list.length <= 1 || isSaving}
|
|
||||||
>
|
|
||||||
{t('common.delete')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button variant="secondary" size="sm" onClick={addEntry} disabled={isSaving}>
|
|
||||||
{t('ai_providers.openai_keys_add_btn')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openOpenaiModelDiscovery = () => {
|
|
||||||
const baseUrl = form.baseUrl.trim();
|
|
||||||
if (!baseUrl) {
|
|
||||||
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDiscoveryOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyOpenaiModelDiscoverySelection = (selectedModels: ModelInfo[]) => {
|
|
||||||
if (!selectedModels.length) {
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedMap = new Map<string, ModelEntry>();
|
|
||||||
form.modelEntries.forEach((entry) => {
|
|
||||||
const name = entry.name.trim();
|
|
||||||
if (!name) return;
|
|
||||||
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
|
|
||||||
});
|
|
||||||
|
|
||||||
let addedCount = 0;
|
|
||||||
selectedModels.forEach((model) => {
|
|
||||||
const name = model.name.trim();
|
|
||||||
if (!name || mergedMap.has(name)) return;
|
|
||||||
mergedMap.set(name, { name, alias: model.alias ?? '' });
|
|
||||||
addedCount += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const mergedEntries = Array.from(mergedMap.values());
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
if (addedCount > 0) {
|
|
||||||
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const testOpenaiProviderConnection = async () => {
|
|
||||||
const baseUrl = form.baseUrl.trim();
|
|
||||||
if (!baseUrl) {
|
|
||||||
const message = t('notification.openai_test_url_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
|
|
||||||
if (!endpoint) {
|
|
||||||
const message = t('notification.openai_test_url_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim());
|
|
||||||
if (!firstKeyEntry) {
|
|
||||||
const message = t('notification.openai_test_key_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelName = testModel.trim() || availableModels[0] || '';
|
|
||||||
if (!modelName) {
|
|
||||||
const message = t('notification.openai_test_model_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const customHeaders = buildHeaderObject(form.headers);
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...customHeaders,
|
|
||||||
};
|
|
||||||
if (!headers.Authorization && !headers['authorization']) {
|
|
||||||
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestStatus('loading');
|
|
||||||
setTestMessage(t('ai_providers.openai_test_running'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await apiCallApi.request(
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
url: endpoint,
|
|
||||||
header: Object.keys(headers).length ? headers : undefined,
|
|
||||||
data: JSON.stringify({
|
|
||||||
model: modelName,
|
|
||||||
messages: [{ role: 'user', content: 'Hi' }],
|
|
||||||
stream: false,
|
|
||||||
max_tokens: 5,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{ timeout: OPENAI_TEST_TIMEOUT_MS }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
|
||||||
throw new Error(getApiCallErrorMessage(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestStatus('success');
|
|
||||||
setTestMessage(t('ai_providers.openai_test_success'));
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setTestStatus('error');
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
const errorCode =
|
|
||||||
typeof err === 'object' && err !== null && 'code' in err ? String((err as { code?: string }).code) : '';
|
|
||||||
const isTimeout =
|
|
||||||
errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
|
|
||||||
if (isTimeout) {
|
|
||||||
setTestMessage(t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 }));
|
|
||||||
} else {
|
|
||||||
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.openai_edit_modal_title')
|
|
||||||
: t('ai_providers.openai_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.openai_add_modal_name_label')}
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.openai_add_modal_url_label')}
|
|
||||||
value={form.baseUrl}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>
|
|
||||||
{editIndex !== null
|
|
||||||
? t('ai_providers.openai_edit_modal_models_label')
|
|
||||||
: t('ai_providers.openai_add_modal_models_label')}
|
|
||||||
</label>
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
|
|
||||||
<ModelInputList
|
|
||||||
entries={form.modelEntries}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
|
||||||
addLabel={t('ai_providers.openai_models_add_btn')}
|
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
|
||||||
disabled={isSaving}
|
|
||||||
/>
|
|
||||||
<Button variant="secondary" size="sm" onClick={openOpenaiModelDiscovery} disabled={isSaving}>
|
|
||||||
{t('ai_providers.openai_models_fetch_button')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.openai_test_title')}</label>
|
|
||||||
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<select
|
|
||||||
className={`input ${styles.openaiTestSelect}`}
|
|
||||||
value={testModel}
|
|
||||||
onChange={(e) => {
|
|
||||||
setTestModel(e.target.value);
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
}}
|
|
||||||
disabled={isSaving || availableModels.length === 0}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{availableModels.length
|
|
||||||
? t('ai_providers.openai_test_select_placeholder')
|
|
||||||
: t('ai_providers.openai_test_select_empty')}
|
|
||||||
</option>
|
|
||||||
{form.modelEntries
|
|
||||||
.filter((entry) => entry.name.trim())
|
|
||||||
.map((entry, idx) => {
|
|
||||||
const name = entry.name.trim();
|
|
||||||
const alias = entry.alias.trim();
|
|
||||||
const label = alias && alias !== name ? `${name} (${alias})` : name;
|
|
||||||
return (
|
|
||||||
<option key={`${name}-${idx}`} value={name}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
|
||||||
className={`${styles.openaiTestButton} ${testStatus === 'success' ? styles.openaiTestButtonSuccess : ''}`}
|
|
||||||
onClick={testOpenaiProviderConnection}
|
|
||||||
loading={testStatus === 'loading'}
|
|
||||||
disabled={isSaving || availableModels.length === 0}
|
|
||||||
>
|
|
||||||
{t('ai_providers.openai_test_action')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{testMessage && (
|
|
||||||
<div
|
|
||||||
className={`status-badge ${
|
|
||||||
testStatus === 'error' ? 'error' : testStatus === 'success' ? 'success' : 'muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{testMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
|
||||||
{renderKeyEntries(form.apiKeyEntries)}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<OpenAIDiscoveryModal
|
|
||||||
isOpen={discoveryOpen}
|
|
||||||
baseUrl={form.baseUrl}
|
|
||||||
headers={form.headers}
|
|
||||||
apiKeyEntries={form.apiKeyEntries}
|
|
||||||
onClose={() => setDiscoveryOpen(false)}
|
|
||||||
onApply={applyOpenaiModelDiscoverySelection}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
|
||||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
|
||||||
import { headersToEntries } from '@/utils/headers';
|
|
||||||
import type { ProviderModalProps, VertexFormState } from '../types';
|
|
||||||
|
|
||||||
interface VertexModalProps extends ProviderModalProps<ProviderKeyConfig, VertexFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): VertexFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
proxyUrl: '',
|
|
||||||
headers: [],
|
|
||||||
models: [],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
export function VertexModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: VertexModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<VertexFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
modelEntries: modelsToEntries(initialData.models),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.vertex_edit_modal_title')
|
|
||||||
: t('ai_providers.vertex_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.vertex_add_modal_key_label')}
|
|
||||||
placeholder={t('ai_providers.vertex_add_modal_key_placeholder')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.vertex_add_modal_url_label')}
|
|
||||||
placeholder={t('ai_providers.vertex_add_modal_url_placeholder')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.vertex_add_modal_proxy_label')}
|
|
||||||
placeholder={t('ai_providers.vertex_add_modal_proxy_placeholder')}
|
|
||||||
value={form.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.vertex_models_label')}</label>
|
|
||||||
<ModelInputList
|
|
||||||
entries={form.modelEntries}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
|
||||||
addLabel={t('ai_providers.vertex_models_add_btn')}
|
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
|
||||||
disabled={isSaving}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,6 @@ import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
|
|||||||
import type { HeaderEntry } from '@/utils/headers';
|
import type { HeaderEntry } from '@/utils/headers';
|
||||||
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
||||||
|
|
||||||
export type ProviderModal =
|
|
||||||
| { type: 'gemini'; index: number | null }
|
|
||||||
| { type: 'codex'; index: number | null }
|
|
||||||
| { type: 'claude'; index: number | null }
|
|
||||||
| { type: 'vertex'; index: number | null }
|
|
||||||
| { type: 'ampcode'; index: null }
|
|
||||||
| { type: 'openai'; index: number | null };
|
|
||||||
|
|
||||||
export interface ModelEntry {
|
export interface ModelEntry {
|
||||||
name: string;
|
name: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
@@ -58,12 +50,3 @@ export interface ProviderSectionProps<TConfig> {
|
|||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onToggle?: (index: number, enabled: boolean) => void;
|
onToggle?: (index: number, enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderModalProps<TConfig, TPayload = TConfig> {
|
|
||||||
isOpen: boolean;
|
|
||||||
editIndex: number | null;
|
|
||||||
initialData?: TConfig;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (data: TPayload, index: number | null) => Promise<void>;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user