mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(ai-providers): confirm before leaving with unsaved changes
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type { BlockerFunction } from 'react-router';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
|
||||
type ConfirmationVariant = 'danger' | 'primary' | 'secondary';
|
||||
|
||||
export type UnsavedChangesDialog = {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
variant?: ConfirmationVariant;
|
||||
};
|
||||
|
||||
export type UseUnsavedChangesGuardOptions = {
|
||||
enabled?: boolean;
|
||||
shouldBlock: boolean | BlockerFunction;
|
||||
dialog: UnsavedChangesDialog;
|
||||
};
|
||||
|
||||
export function useUnsavedChangesGuard(options: UseUnsavedChangesGuardOptions) {
|
||||
const { enabled = true, shouldBlock, dialog } = options;
|
||||
const { showConfirmation } = useNotificationStore();
|
||||
const lastBlockedRef = useRef<string>('');
|
||||
|
||||
const shouldBlockFunction = useCallback<BlockerFunction>(
|
||||
(args) => {
|
||||
if (!enabled) return false;
|
||||
return typeof shouldBlock === 'function' ? shouldBlock(args) : shouldBlock;
|
||||
},
|
||||
[enabled, shouldBlock]
|
||||
);
|
||||
|
||||
const blocker = useBlocker(shouldBlockFunction);
|
||||
|
||||
const blockedKey = useMemo(() => {
|
||||
if (blocker.state !== 'blocked' || !blocker.location) return '';
|
||||
return `${blocker.location.pathname}${blocker.location.search}${blocker.location.hash}`;
|
||||
}, [blocker.location, blocker.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state !== 'blocked') {
|
||||
lastBlockedRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blockedKey || lastBlockedRef.current === blockedKey) {
|
||||
return;
|
||||
}
|
||||
lastBlockedRef.current = blockedKey;
|
||||
|
||||
showConfirmation({
|
||||
title: dialog.title,
|
||||
message: dialog.message,
|
||||
confirmText: dialog.confirmText,
|
||||
cancelText: dialog.cancelText,
|
||||
variant: dialog.variant ?? 'danger',
|
||||
onConfirm: () => blocker.proceed(),
|
||||
onCancel: () => blocker.reset(),
|
||||
});
|
||||
}, [blockedKey, blocker, dialog, showConfirmation]);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"leave": "Leave",
|
||||
"stay": "Stay",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
@@ -48,7 +50,9 @@
|
||||
"custom_headers_value_placeholder": "Header value",
|
||||
"model_name_placeholder": "Model name, e.g. claude-3-5-sonnet-20241022",
|
||||
"model_alias_placeholder": "Model alias (optional)",
|
||||
"invalid_provider_index": "Invalid provider index."
|
||||
"invalid_provider_index": "Invalid provider index.",
|
||||
"unsaved_changes_title": "Unsaved changes",
|
||||
"unsaved_changes_message": "You have unsaved changes. Leaving now will discard them. Do you want to leave?"
|
||||
},
|
||||
"title": {
|
||||
"main": "CLI Proxy API Management Center",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"back": "Назад",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить",
|
||||
"leave": "Уйти",
|
||||
"stay": "Остаться",
|
||||
"save": "Сохранить",
|
||||
"delete": "Удалить",
|
||||
"edit": "Редактировать",
|
||||
@@ -48,7 +50,9 @@
|
||||
"custom_headers_value_placeholder": "Значение заголовка",
|
||||
"model_name_placeholder": "Имя модели, напр. claude-3-5-sonnet-20241022",
|
||||
"model_alias_placeholder": "Псевдоним модели (необязательно)",
|
||||
"invalid_provider_index": "Неверный индекс провайдера."
|
||||
"invalid_provider_index": "Неверный индекс провайдера.",
|
||||
"unsaved_changes_title": "Несохранённые изменения",
|
||||
"unsaved_changes_message": "У вас есть несохранённые изменения. Если вы уйдёте, они будут потеряны. Выйти?"
|
||||
},
|
||||
"title": {
|
||||
"main": "Центр управления CLI Proxy API",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"back": "返回",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"leave": "离开",
|
||||
"stay": "继续编辑",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
@@ -48,7 +50,9 @@
|
||||
"custom_headers_value_placeholder": "Header 值",
|
||||
"model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022",
|
||||
"model_alias_placeholder": "模型别名 (可选)",
|
||||
"invalid_provider_index": "无效的提供商索引。"
|
||||
"invalid_provider_index": "无效的提供商索引。",
|
||||
"unsaved_changes_title": "未保存的更改",
|
||||
"unsaved_changes_message": "你有未保存的更改,离开后将丢失这些更改。确定要离开吗?"
|
||||
},
|
||||
"title": {
|
||||
"main": "CLI Proxy API Management Center",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { ampcodeApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
@@ -24,6 +25,23 @@ const getErrorMessage = (err: unknown) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeMappingEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
(entries ?? []).reduce<Array<{ from: string; to: string }>>((acc, entry) => {
|
||||
const from = String(entry?.name ?? '').trim();
|
||||
const to = String(entry?.alias ?? '').trim();
|
||||
if (!from && !to) return acc;
|
||||
acc.push({ from, to });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const buildAmpcodeSignature = (form: AmpcodeFormState) =>
|
||||
JSON.stringify({
|
||||
upstreamUrl: String(form.upstreamUrl ?? '').trim(),
|
||||
upstreamApiKey: String(form.upstreamApiKey ?? '').trim(),
|
||||
forceModelMappings: Boolean(form.forceModelMappings),
|
||||
modelMappings: normalizeMappingEntries(form.mappingEntries),
|
||||
});
|
||||
|
||||
export function AiProvidersAmpcodeEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -42,6 +60,9 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
const [mappingsDirty, setMappingsDirty] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [baselineSignature, setBaselineSignature] = useState(() =>
|
||||
buildAmpcodeSignature(buildAmpcodeFormState(null))
|
||||
);
|
||||
const initializedRef = useRef(false);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
@@ -83,7 +104,9 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
setLoaded(false);
|
||||
setMappingsDirty(false);
|
||||
setError('');
|
||||
setForm(buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null));
|
||||
const initialForm = buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null);
|
||||
setForm(initialForm);
|
||||
setBaselineSignature(buildAmpcodeSignature(initialForm));
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -93,7 +116,9 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
setLoaded(true);
|
||||
updateConfigValue('ampcode', ampcode);
|
||||
clearCache('ampcode');
|
||||
setForm(buildAmpcodeFormState(ampcode));
|
||||
const nextForm = buildAmpcodeFormState(ampcode);
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildAmpcodeSignature(nextForm));
|
||||
} catch (err: unknown) {
|
||||
if (!mountedRef.current) return;
|
||||
setError(getErrorMessage(err) || t('notification.refresh_failed'));
|
||||
@@ -105,6 +130,23 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
})();
|
||||
}, [clearCache, t, updateConfigValue]);
|
||||
|
||||
const currentSignature = useMemo(() => buildAmpcodeSignature(form), [form]);
|
||||
const isDirty = baselineSignature !== currentSignature;
|
||||
const canGuard = !loading && !saving;
|
||||
|
||||
useUnsavedChangesGuard({
|
||||
enabled: canGuard,
|
||||
shouldBlock: ({ currentLocation, nextLocation }) =>
|
||||
isDirty && currentLocation.pathname !== nextLocation.pathname,
|
||||
dialog: {
|
||||
title: t('common.unsaved_changes_title'),
|
||||
message: t('common.unsaved_changes_message'),
|
||||
confirmText: t('common.leave'),
|
||||
cancelText: t('common.stay'),
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
|
||||
const clearAmpcodeUpstreamApiKey = async () => {
|
||||
showConfirmation({
|
||||
title: t('ai_providers.ampcode_clear_upstream_api_key_title', {
|
||||
|
||||
@@ -2,12 +2,13 @@ import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useAuthStore, useClaudeEditDraftStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import type { ModelEntry, ProviderFormState } from '@/components/providers/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
|
||||
@@ -62,6 +63,59 @@ const getErrorMessage = (err: unknown) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeClaudeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = String(entry?.name ?? '').trim();
|
||||
let alias = String(entry?.alias ?? '').trim();
|
||||
if (name) {
|
||||
alias = alias || name;
|
||||
}
|
||||
if (!name && !alias) return acc;
|
||||
acc.push({ name, alias });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const normalizeCloakConfig = (cloak: ProviderFormState['cloak']) => {
|
||||
if (!cloak) return null;
|
||||
const mode = String(cloak.mode ?? '').trim().toLowerCase() || 'auto';
|
||||
const strictMode = Boolean(cloak.strictMode);
|
||||
const sensitiveWords = Array.isArray(cloak.sensitiveWords)
|
||||
? cloak.sensitiveWords.map((word) => String(word ?? '').trim()).filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
mode,
|
||||
strictMode,
|
||||
sensitiveWords: sensitiveWords.length ? sensitiveWords : null,
|
||||
};
|
||||
};
|
||||
|
||||
const buildClaudeSignature = (form: ProviderFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
headers: normalizeHeaderEntries(form.headers),
|
||||
models: normalizeClaudeModelEntries(form.modelEntries),
|
||||
excludedModels: parseExcludedModels(form.excludedText ?? ''),
|
||||
cloak: normalizeCloakConfig(form.cloak),
|
||||
});
|
||||
|
||||
export function AiProvidersClaudeEditLayout() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -85,6 +139,8 @@ export function AiProvidersClaudeEditLayout() {
|
||||
const [configs, setConfigs] = useState<ProviderKeyConfig[]>(() => config?.claudeApiKeys ?? []);
|
||||
const [loading, setLoading] = useState(() => !isCacheValid('claude-api-key'));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [baselineDraftKey, setBaselineDraftKey] = useState<string>('');
|
||||
const [baselineSignature, setBaselineSignature] = useState<string>('');
|
||||
|
||||
const draftKey = useMemo(() => {
|
||||
if (invalidIndexParam) return `claude:invalid:${params.index ?? 'unknown'}`;
|
||||
@@ -151,14 +207,19 @@ export function AiProvidersClaudeEditLayout() {
|
||||
}, [draftKey, ensureDraft]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
clearDraft(draftKey);
|
||||
const state = location.state as LocationState;
|
||||
if (state?.fromAiProviders) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
navigate('/ai-providers', { replace: true });
|
||||
}, [clearDraft, draftKey, location.state, navigate]);
|
||||
}, [location.state, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearDraft(draftKey);
|
||||
};
|
||||
}, [clearDraft, draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -217,6 +278,40 @@ export function AiProvidersClaudeEditLayout() {
|
||||
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
|
||||
|
||||
const resolvedLoading = !draft?.initialized;
|
||||
const currentSignature = useMemo(() => buildClaudeSignature(form), [form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resolvedLoading) return;
|
||||
if (baselineDraftKey === draftKey) return;
|
||||
setBaselineDraftKey(draftKey);
|
||||
setBaselineSignature(currentSignature);
|
||||
}, [baselineDraftKey, currentSignature, draftKey, resolvedLoading]);
|
||||
|
||||
const isDirty = baselineDraftKey === draftKey && baselineSignature !== currentSignature;
|
||||
const editorRootPath = useMemo(() => {
|
||||
if (hasIndexParam) {
|
||||
return `/ai-providers/claude/${params.index ?? ''}`;
|
||||
}
|
||||
return '/ai-providers/claude/new';
|
||||
}, [hasIndexParam, params.index]);
|
||||
const canGuard = !resolvedLoading && !saving && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
useUnsavedChangesGuard({
|
||||
enabled: canGuard,
|
||||
shouldBlock: ({ nextLocation }) => {
|
||||
const nextPath = nextLocation.pathname;
|
||||
const isWithinRoot =
|
||||
nextPath === editorRootPath || nextPath.startsWith(`${editorRootPath}/`);
|
||||
return isDirty && !isWithinRoot;
|
||||
},
|
||||
dialog: {
|
||||
title: t('common.unsaved_changes_title'),
|
||||
message: t('common.unsaved_changes_message'),
|
||||
confirmText: t('common.leave'),
|
||||
cancelText: t('common.stay'),
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (resolvedLoading) return;
|
||||
|
||||
@@ -9,11 +9,12 @@ import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { modelsApi, providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
import type { ProviderFormState } from '@/components/providers';
|
||||
@@ -49,6 +50,45 @@ const getErrorMessage = (err: unknown) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = String(entry?.name ?? '').trim();
|
||||
let alias = String(entry?.alias ?? '').trim();
|
||||
if (name && alias === name) {
|
||||
alias = '';
|
||||
}
|
||||
if (!name && !alias) return acc;
|
||||
acc.push({ name, alias });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const buildCodexSignature = (form: ProviderFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
websockets: Boolean(form.websockets),
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
headers: normalizeHeaderEntries(form.headers),
|
||||
models: normalizeModelEntries(form.modelEntries),
|
||||
excludedModels: parseExcludedModels(form.excludedText ?? ''),
|
||||
});
|
||||
|
||||
export function AiProvidersCodexEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -68,6 +108,7 @@ export function AiProvidersCodexEditPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
|
||||
const [baselineSignature, setBaselineSignature] = useState(() => buildCodexSignature(buildEmptyForm()));
|
||||
|
||||
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
|
||||
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
|
||||
@@ -142,17 +183,38 @@ export function AiProvidersCodexEditPage() {
|
||||
if (loading) return;
|
||||
|
||||
if (initialData) {
|
||||
setForm({
|
||||
const nextForm: ProviderFormState = {
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
modelEntries: modelsToEntries(initialData.models),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
};
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildCodexSignature(nextForm));
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
const nextForm = buildEmptyForm();
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildCodexSignature(nextForm));
|
||||
}, [initialData, loading]);
|
||||
|
||||
const currentSignature = useMemo(() => buildCodexSignature(form), [form]);
|
||||
const isDirty = baselineSignature !== currentSignature;
|
||||
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
useUnsavedChangesGuard({
|
||||
enabled: canGuard,
|
||||
shouldBlock: ({ currentLocation, nextLocation }) =>
|
||||
isDirty && currentLocation.pathname !== nextLocation.pathname,
|
||||
dialog: {
|
||||
title: t('common.unsaved_changes_title'),
|
||||
message: t('common.unsaved_changes_message'),
|
||||
confirmText: t('common.leave'),
|
||||
cancelText: t('common.stay'),
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
|
||||
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
const discoveredModelsFiltered = useMemo(() => {
|
||||
|
||||
@@ -8,11 +8,12 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { modelsApi, providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { GeminiKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
@@ -44,6 +45,44 @@ const stripGeminiModelResourceName = (value: string) => {
|
||||
return String(value ?? '').trim().replace(/^\/?models\//i, '');
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = stripGeminiModelResourceName(entry?.name ?? '').trim();
|
||||
let alias = String(entry?.alias ?? '').trim();
|
||||
if (name && alias === name) {
|
||||
alias = '';
|
||||
}
|
||||
if (!name && !alias) return acc;
|
||||
acc.push({ name, alias });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const buildGeminiSignature = (form: GeminiFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
headers: normalizeHeaderEntries(form.headers),
|
||||
models: normalizeModelEntries(form.modelEntries),
|
||||
excludedModels: parseExcludedModels(form.excludedText ?? ''),
|
||||
});
|
||||
|
||||
export function AiProvidersGeminiEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -63,6 +102,7 @@ export function AiProvidersGeminiEditPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<GeminiFormState>(() => buildEmptyForm());
|
||||
const [baselineSignature, setBaselineSignature] = useState(() => buildGeminiSignature(buildEmptyForm()));
|
||||
|
||||
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
|
||||
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
|
||||
@@ -138,7 +178,7 @@ export function AiProvidersGeminiEditPage() {
|
||||
|
||||
if (initialData) {
|
||||
const { headers, models, ...rest } = initialData;
|
||||
setForm({
|
||||
const nextForm: GeminiFormState = {
|
||||
...rest,
|
||||
headers: headersToEntries(headers),
|
||||
modelEntries: modelsToEntries(models).map((entry) => ({
|
||||
@@ -146,10 +186,14 @@ export function AiProvidersGeminiEditPage() {
|
||||
name: stripGeminiModelResourceName(entry.name),
|
||||
})),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
};
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildGeminiSignature(nextForm));
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
const nextForm = buildEmptyForm();
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildGeminiSignature(nextForm));
|
||||
}, [initialData, loading]);
|
||||
|
||||
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
@@ -287,6 +331,23 @@ export function AiProvidersGeminiEditPage() {
|
||||
setModelDiscoveryOpen(false);
|
||||
};
|
||||
|
||||
const currentSignature = useMemo(() => buildGeminiSignature(form), [form]);
|
||||
const isDirty = baselineSignature !== currentSignature;
|
||||
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
useUnsavedChangesGuard({
|
||||
enabled: canGuard,
|
||||
shouldBlock: ({ currentLocation, nextLocation }) =>
|
||||
isDirty && currentLocation.pathname !== nextLocation.pathname,
|
||||
dialog: {
|
||||
title: t('common.unsaved_changes_title'),
|
||||
message: t('common.unsaved_changes_message'),
|
||||
confirmText: t('common.leave'),
|
||||
cancelText: t('common.stay'),
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useOpenAIEditDraftStore } from '@/stores';
|
||||
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import { buildApiKeyEntry } from '@/components/providers/utils';
|
||||
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
|
||||
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
|
||||
@@ -62,6 +63,72 @@ const getErrorMessage = (err: unknown) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeModelEntries = (entries: ModelEntry[]) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = String(entry?.name ?? '').trim();
|
||||
let alias = String(entry?.alias ?? '').trim();
|
||||
if (name && (alias === '' || alias === name)) {
|
||||
alias = '';
|
||||
}
|
||||
if (!name && !alias) return acc;
|
||||
acc.push({ name, alias });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const normalizeKeyHeaders = (headers: ApiKeyEntry['headers']) => {
|
||||
if (!headers || typeof headers !== 'object') return [];
|
||||
return Object.entries(headers)
|
||||
.map(([key, value]) => ({ key: String(key ?? '').trim(), value: String(value ?? '').trim() }))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeApiKeyEntries = (entries: ApiKeyEntry[]) =>
|
||||
(entries ?? []).reduce<
|
||||
Array<{
|
||||
apiKey: string;
|
||||
proxyUrl: string;
|
||||
headers: Array<{ key: string; value: string }>;
|
||||
}>
|
||||
>((acc, entry) => {
|
||||
const apiKey = String(entry?.apiKey ?? '').trim();
|
||||
const proxyUrl = String(entry?.proxyUrl ?? '').trim();
|
||||
const headers = normalizeKeyHeaders(entry?.headers);
|
||||
if (!apiKey && !proxyUrl && headers.length === 0) return acc;
|
||||
acc.push({ apiKey, proxyUrl, headers });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const buildOpenAISignature = (form: OpenAIFormState, testModel: string) =>
|
||||
JSON.stringify({
|
||||
name: String(form.name ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
headers: normalizeHeaderEntries(form.headers),
|
||||
apiKeyEntries: normalizeApiKeyEntries(form.apiKeyEntries),
|
||||
models: normalizeModelEntries(form.modelEntries),
|
||||
testModel: String(testModel ?? '').trim(),
|
||||
});
|
||||
|
||||
export function AiProvidersOpenAIEditLayout() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -87,6 +154,8 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
() => !isCacheValid('openai-compatibility')
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [baselineDraftKey, setBaselineDraftKey] = useState<string>('');
|
||||
const [baselineSignature, setBaselineSignature] = useState<string>('');
|
||||
|
||||
const draftKey = useMemo(() => {
|
||||
if (invalidIndexParam) return `openai:invalid:${params.index ?? 'unknown'}`;
|
||||
@@ -171,14 +240,19 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
}, [draftKey, ensureDraft]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
clearDraft(draftKey);
|
||||
const state = location.state as LocationState;
|
||||
if (state?.fromAiProviders) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
navigate('/ai-providers', { replace: true });
|
||||
}, [clearDraft, draftKey, location.state, navigate]);
|
||||
}, [location.state, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearDraft(draftKey);
|
||||
};
|
||||
}, [clearDraft, draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -374,6 +448,40 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
]);
|
||||
|
||||
const resolvedLoading = !draft?.initialized;
|
||||
const currentSignature = useMemo(() => buildOpenAISignature(form, testModel), [form, testModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resolvedLoading) return;
|
||||
if (baselineDraftKey === draftKey) return;
|
||||
setBaselineDraftKey(draftKey);
|
||||
setBaselineSignature(currentSignature);
|
||||
}, [baselineDraftKey, currentSignature, draftKey, resolvedLoading]);
|
||||
|
||||
const isDirty = baselineDraftKey === draftKey && baselineSignature !== currentSignature;
|
||||
const editorRootPath = useMemo(() => {
|
||||
if (hasIndexParam) {
|
||||
return `/ai-providers/openai/${params.index ?? ''}`;
|
||||
}
|
||||
return '/ai-providers/openai/new';
|
||||
}, [hasIndexParam, params.index]);
|
||||
const canGuard = !resolvedLoading && !saving && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
useUnsavedChangesGuard({
|
||||
enabled: canGuard,
|
||||
shouldBlock: ({ nextLocation }) => {
|
||||
const nextPath = nextLocation.pathname;
|
||||
const isWithinRoot =
|
||||
nextPath === editorRootPath || nextPath.startsWith(`${editorRootPath}/`);
|
||||
return isDirty && !isWithinRoot;
|
||||
},
|
||||
dialog: {
|
||||
title: t('common.unsaved_changes_title'),
|
||||
message: t('common.unsaved_changes_message'),
|
||||
confirmText: t('common.leave'),
|
||||
cancelText: t('common.stay'),
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Outlet
|
||||
|
||||
@@ -8,11 +8,12 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import type { VertexFormState } from '@/components/providers';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
|
||||
@@ -34,6 +35,38 @@ const parseIndexParam = (value: string | undefined) => {
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = String(entry?.name ?? '').trim();
|
||||
const alias = String(entry?.alias ?? '').trim();
|
||||
if (!name && !alias) return acc;
|
||||
acc.push({ name, alias });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const buildVertexSignature = (form: VertexFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
headers: normalizeHeaderEntries(form.headers),
|
||||
models: normalizeModelEntries(form.modelEntries),
|
||||
});
|
||||
|
||||
export function AiProvidersVertexEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -53,6 +86,7 @@ export function AiProvidersVertexEditPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<VertexFormState>(() => buildEmptyForm());
|
||||
const [baselineSignature, setBaselineSignature] = useState(() => buildVertexSignature(buildEmptyForm()));
|
||||
|
||||
const hasIndexParam = typeof params.index === 'string';
|
||||
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||
@@ -126,18 +160,39 @@ export function AiProvidersVertexEditPage() {
|
||||
if (loading) return;
|
||||
|
||||
if (initialData) {
|
||||
setForm({
|
||||
const nextForm: VertexFormState = {
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
modelEntries: modelsToEntries(initialData.models),
|
||||
});
|
||||
};
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildVertexSignature(nextForm));
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
const nextForm = buildEmptyForm();
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildVertexSignature(nextForm));
|
||||
}, [initialData, loading]);
|
||||
|
||||
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
const currentSignature = useMemo(() => buildVertexSignature(form), [form]);
|
||||
const isDirty = baselineSignature !== currentSignature;
|
||||
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
useUnsavedChangesGuard({
|
||||
enabled: canGuard,
|
||||
shouldBlock: ({ currentLocation, nextLocation }) =>
|
||||
isDirty && currentLocation.pathname !== nextLocation.pathname,
|
||||
dialog: {
|
||||
title: t('common.unsaved_changes_title'),
|
||||
message: t('common.unsaved_changes_message'),
|
||||
confirmText: t('common.leave'),
|
||||
cancelText: t('common.stay'),
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user